diff --git a/apps/web/app/space/[id]/(space)/debates/debates-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/debates-page-client.test.tsx index e4d9ecf0e6..cf449ef064 100644 --- a/apps/web/app/space/[id]/(space)/debates/debates-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/debates-page-client.test.tsx @@ -59,6 +59,9 @@ vi.mock('~/core/debates/hooks', () => ({ useDebateMedia: () => ({ data: undefined, isLoading: false, isError: false }), useDebateTranscript: () => ({ data: { segments: [] }, isLoading: false, error: null }), useDebateClaims: () => ({ data: { claims: [] } }), + // Reached through the player's claim ticker, which asks per space for the rows behind each + // claim. This suite's debates carry no claims, so it answers with none. + useDebateClaimsBySpaces: () => ({ claims: [], isLoading: false, isError: false }), // The feed resolves an anchor by id when the space listing does not contain it (GEO-2764). // These tests never anchor, so it stays idle. useDebate: () => ({ data: null, isLoading: false, error: null }), @@ -99,13 +102,18 @@ vi.mock('~/core/hooks/use-comments', () => ({ // The feed's Claims badge reads the debate's transcript claims through react-query, and this // suite renders the feed without a QueryClientProvider. Stub it the way the other debate suites do; // the grouping and ordering have their own unit tests. -vi.mock('~/core/debates/use-debate-transcript-claims', () => ({ - useDebateTranscriptClaims: () => ({ - claims: { all: [], byAuthorSpaceId: new Map(), unattributed: [], totalCount: 0 }, - isLoading: false, - error: null, - }), -})); +vi.mock('~/core/debates/use-debate-transcript-claims', async () => { + // The real empty value rather than a hand-rolled copy of it. A literal here has to be updated + // every time the shape grows a field, and when it isn't, it fails as a runtime TypeError in a + // suite that has nothing to do with claims. + const { EMPTY_TRANSCRIPT_CLAIMS } = await vi.importActual( + '~/core/debates/transcript-claims' + ); + + return { + useDebateTranscriptClaims: () => ({ claims: EMPTY_TRANSCRIPT_CLAIMS, isLoading: false, error: null }), + }; +}); vi.mock('~/core/hooks/use-entity-side-panel', () => ({ useEntitySidePanel: () => ({ openSidePanel: mocks.openSidePanel, closeSidePanel: vi.fn(), sidePanelTarget: null }), @@ -139,7 +147,11 @@ describe('DebatesPageClient browse feed', () => { expect(screen.getByRole('heading', { name: 'Debates are useful' })).toBeInTheDocument(); expect(screen.getAllByText('Fashion').length).toBeGreaterThan(0); expect(screen.getAllByRole('button', { name: 'Join a debate' }).length).toBeGreaterThan(0); - expect(screen.getAllByText('Winner?').length).toBeGreaterThan(0); + // Both debaters name themselves on their own tile. The "Winner?" pill used to sit here too; + // it moved off the tile entirely when the name row took the bottom-right corner, and winner + // voting now happens on the end-of-debate scorecard and in the claims panel. + expect(screen.getAllByText('Alex').length).toBeGreaterThan(0); + expect(screen.getAllByText('Sam').length).toBeGreaterThan(0); expect(screen.getAllByTestId('entity-vote-buttons')).toHaveLength(2); await waitFor(() => expect(container.querySelectorAll('video')).toHaveLength(2)); diff --git a/apps/web/core/debates/browse/debate-claim-ticker.test.tsx b/apps/web/core/debates/browse/debate-claim-ticker.test.tsx new file mode 100644 index 0000000000..646e4cfd2c --- /dev/null +++ b/apps/web/core/debates/browse/debate-claim-ticker.test.tsx @@ -0,0 +1,937 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { DebateParticipant } from '~/core/debates/api'; +import type { ClaimMarker, TickerWindow } from '~/core/debates/claim-ticker'; +import type { TimedClaim } from '~/core/debates/claim-timing'; + +import { + ClaimScrubberMarkers, + DebateClaimTickerCard, + DebateClaimTickerStack, + markerHitWidth, + useDebateClaimTicker, +} from './debate-claim-ticker'; + +const CLAIM_SPACE = '52c7ae149838b6d47ce0f3b2a5974546'; + +const mocks = vi.hoisted(() => ({ + /** What `useDebateTranscriptClaims` hands the hook, for the `useDebateClaimTicker` suite. */ + transcriptClaims: { all: [], blocks: [], byAuthorSpaceId: new Map() } as { + all: TimedClaim[]; + blocks: Array<{ id: string; authorSpaceId: string | null }>; + byAuthorSpaceId: Map; + }, + /** What the shared position control reports the viewer currently holds — a side, not a string. */ + viewerPosition: null as boolean | null, + /** The crowd's share of positive responses, or null on a claim nobody has answered. */ + percent: null as number | null, + responseKind: 'stance' as 'stance' | 'veracity', + respond: vi.fn(), +})); + +vi.mock('~/design-system/avatar', () => ({ Avatar: () =>
})); + +vi.mock('~/core/claims/browse/use-claim-response-state', () => ({ + useClaimResponseState: () => ({ + responseKind: mocks.responseKind, + isResponseKindResolved: true, + isViewerResponseResolved: true, + responseBlockedReason: null, + summary: { isLoading: false, percent: mocks.percent }, + claim: { id: 'claim-1' }, + positions: [], + readiness: {}, + }), +})); + +vi.mock('~/core/debates/matchmaking/matchmaking-claim-card', () => ({ + PositionRow: ({ onRespond, disabled }: { onRespond: (side: string) => void; disabled?: boolean }) => ( + + ), + useClaimPositionControl: () => ({ + viewerPosition: mocks.viewerPosition, + optimisticPositions: [], + respond: mocks.respond, + actionTitle: () => '', + responseError: null, + canRespond: true, + }), +})); + +vi.mock('~/core/hooks/use-privy-sign-in', () => ({ usePrivySignIn: () => vi.fn() })); + +// The card's header names its speaker and links to them, which reaches for the side panel and for +// the space behind the profile. Neither is what these tests are about. +const openSidePanel = vi.fn(); +vi.mock('~/core/hooks/use-entity-side-panel', () => ({ + useEntitySidePanel: () => ({ openSidePanel, closeSidePanel: vi.fn(), sidePanelTarget: null }), +})); +vi.mock('~/core/hooks/use-space', () => ({ + useSpace: (spaceId?: string) => ({ space: spaceId ? { entity: { id: `page-${spaceId}` } } : null, isLoading: false }), +})); + +// `useDebateClaimTicker` reads four shared caches. None of them is what the suite below is about — +// it asks what the hook does with the claims once it has them — so each hands back a fixture. +vi.mock('~/core/debates/use-debate-transcript-claims', () => ({ + useDebateTranscriptClaims: () => ({ claims: mocks.transcriptClaims, isLoading: false }), +})); +vi.mock('~/core/debates/use-claim-timings', () => ({ + // The resolver's published path, which is the only one the fixtures below take: a claim already + // carrying offsets is timed at full confidence and the transcript is never read. Reproduced + // rather than stubbed flat so the suite exercises real timings. + useClaimTimings: () => ({ + timings: new Map( + mocks.transcriptClaims.all + .filter(entry => entry.publishedTiming !== null) + .map(entry => [ + entry.id, + { ...(entry.publishedTiming as { startMs: number; endMs: number }), confidence: 1, source: 'published' }, + ]) + ), + isLoading: false, + }), +})); +vi.mock('~/core/sync/use-store', () => ({ useQueryEntities: () => ({ entities: [] }) })); +vi.mock('~/core/debates/hooks', () => ({ useDebateClaimsBySpaces: () => ({ claims: [] }) })); + +function claim(overrides: Partial = {}): TimedClaim { + return { + id: 'claim-1', + text: 'The Supreme Court is no longer providing sufficient checks on executive power', + spaceId: CLAIM_SPACE, + blockId: 'block-1', + publishedTiming: null, + relationEntityId: 'relation-1', + restated: false, + timing: { startMs: 134_600, endMs: 143_140, confidence: 1, source: 'published' }, + ...overrides, + }; +} + +function window(overrides: Partial = {}): TickerWindow { + return { claim: claim(overrides), startMs: 134_600, endMs: 148_140 }; +} + +const SPEAKER = { + display_name: 'Peter Feldip', + profile_space_id: '4582fbbee28a16589154f7e36f1ee3c5', + avatar_cid: null, + participant_slot: 1, +} as unknown as DebateParticipant; + +function renderCard(props: Partial> = {}) { + return render( + + ); +} + +beforeEach(() => { + mocks.viewerPosition = null; + mocks.percent = null; + mocks.responseKind = 'stance'; + mocks.respond.mockClear(); +}); + +afterEach(cleanup); + +describe('DebateClaimTickerCard', () => { + it('quotes the claim', () => { + renderCard(); + + expect(screen.getByText(/Supreme Court is no longer providing/)).toBeInTheDocument(); + }); + + // The card carries its own attribution now, which is what lets the stack live in one fixed + // corner instead of over the speaker's tile. Get this wrong and the card misquotes a real person. + // The same link as the name in the corner of the tile, and the same person. + it('opens the speaker from the card, the way the tile corner does', () => { + openSidePanel.mockClear(); + renderCard(); + + fireEvent.click(screen.getByRole('button', { name: 'Peter Feldip' })); + + expect(openSidePanel).toHaveBeenCalledWith(`page-${SPEAKER.profile_space_id}`, SPEAKER.profile_space_id, false); + }); + + // The video behind is one big play/pause button. + it('does not toggle playback when the speaker is opened', () => { + const onToggle = vi.fn(); + render( +
+ +
+ ); + + fireEvent.click(screen.getByRole('button', { name: 'Peter Feldip' })); + + expect(onToggle).not.toHaveBeenCalled(); + }); + + it('names the debater who said it', () => { + renderCard(); + + expect(screen.getByText('Peter Feldip')).toBeInTheDocument(); + }); + + it('fades with its window rather than holding at full strength', () => { + const { container } = renderCard({ opacity: 0.4 }); + + expect(container.firstElementChild).toHaveStyle({ opacity: '0.4' }); + }); + + // The card above the newest one dissolves into the video; the newest sits at full strength. In + // Figma that is one gradient over the whole stack, reproduced per-card — see `OLDER_CARD_FADE`. + it('shows the crowd split before the viewer has answered, per the design', () => { + mocks.percent = 65; + + renderCard(); + + expect(screen.getByText('65% agree')).toBeInTheDocument(); + }); + + // "65% agree" on "the SEC sued Coinbase" is the wrong sentence; the share takes the same verb + // the rest of the app uses for the claim's own vocabulary. + it("reads the share with the claim's own vocabulary verb", () => { + mocks.percent = 65; + mocks.responseKind = 'veracity'; + + renderCard(); + + expect(screen.getByText('65% verify')).toBeInTheDocument(); + }); + + // A genuine 0% and "nobody has answered" are different statements, and the great majority of + // claims are the second one. + it('says nothing about the split on a claim nobody has answered', () => { + renderCard(); + + expect(screen.queryByText(/%/)).not.toBeInTheDocument(); + }); + + // Small, recessive, and unlabelled on screen — so the accessible name is the only thing telling + // a screen reader which side is which. + it('offers both sides as icons that still name themselves', () => { + renderCard(); + + expect(screen.getByLabelText('Agree')).toBeInTheDocument(); + expect(screen.getByLabelText('Disagree')).toBeInTheDocument(); + }); + + it('shows which side the viewer took', () => { + mocks.viewerPosition = true; + + renderCard(); + + expect(screen.getByLabelText('Agree')).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByLabelText('Disagree')).toHaveAttribute('aria-pressed', 'false'); + }); + + it('reports the answer up so the card is not asked again on a rewind', () => { + mocks.viewerPosition = true; + const onAnswered = vi.fn(); + + renderCard({ onAnswered }); + + // Reports which way, not just that — the end card tallies the sides. + expect(onAnswered).toHaveBeenCalledExactlyOnceWith('claim-1', true); + }); + + /** + * The map has to track the side the viewer *holds*, not the first one they pressed. It used to + * latch, so switching sides or clearing one left the scorecard tallying an answer nobody holds. + */ + it('reports a side the viewer changes, and its removal', () => { + const onAnswered = vi.fn(); + mocks.viewerPosition = true; + const { rerender } = renderCard({ onAnswered }); + expect(onAnswered).toHaveBeenLastCalledWith('claim-1', true); + + mocks.viewerPosition = false; + rerender( + + ); + expect(onAnswered).toHaveBeenLastCalledWith('claim-1', false); + + mocks.viewerPosition = null; + rerender( + + ); + expect(onAnswered).toHaveBeenLastCalledWith('claim-1', null); + }); + + // `null` is the report, not silence — it is how a cleared side reaches the map, which drops the + // entry rather than keeping a stale one. What must never happen is reporting a *side* nobody took. + it('reports no side for a claim the viewer has not answered', () => { + const onAnswered = vi.fn(); + + renderCard({ onAnswered }); + + expect(onAnswered).not.toHaveBeenCalledWith('claim-1', true); + expect(onAnswered).not.toHaveBeenCalledWith('claim-1', false); + }); + + // The video behind the card is one large play/pause button. + it('does not toggle playback when the card itself is clicked', () => { + const onToggle = vi.fn(); + + render( + + ); + fireEvent.click(screen.getByText(/Supreme Court is no longer providing/)); + + expect(onToggle).not.toHaveBeenCalled(); + }); + + // A claim the graph reports no space for cannot be linked or responded to, so the panel renders + // it as plain text. Over the video there is nothing useful to draw at all. + it('draws nothing for a claim with no space to publish a response to', () => { + const { container } = renderCard({ window: { ...window({ spaceId: null }) } }); + + expect(container).toBeEmptyDOMElement(); + }); +}); + +/** + * jsdom lays nothing out, so a clamped element reports zero for both heights and the overflow + * measurement correctly concludes there is nothing to expand. These make it report a fourth line. + */ +function forceClampedOverflow() { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { configurable: true, get: () => 68 }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => 51 }); +} + +describe('the claim text', () => { + afterEach(() => { + // @ts-expect-error — dropping the stubs restores jsdom's own zero-height getters. + delete HTMLElement.prototype.scrollHeight; + // @ts-expect-error — as above. + delete HTMLElement.prototype.clientHeight; + }); + + // Most claims fit in three lines, and a control that visibly does nothing is worse than none. + it('is not a control when the whole claim already fits', () => { + renderCard(); + + expect(screen.queryByTitle('Show the whole claim')).not.toBeInTheDocument(); + }); + + it('expands in place when there is more than the clamp shows, and collapses again', () => { + forceClampedOverflow(); + renderCard(); + + const toggle = screen.getByTitle('Show the whole claim'); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + + fireEvent.click(toggle); + expect(screen.getByTitle('Show less')).toHaveAttribute('aria-expanded', 'true'); + + fireEvent.click(screen.getByTitle('Show less')); + expect(screen.getByTitle('Show the whole claim')).toHaveAttribute('aria-expanded', 'false'); + }); + + // The video behind is one large play/pause button, so reading a claim must not stop the debate. + it('does not toggle playback when expanded', () => { + forceClampedOverflow(); + const onToggle = vi.fn(); + + render( + + ); + fireEvent.click(screen.getByTitle('Show the whole claim')); + + expect(onToggle).not.toHaveBeenCalled(); + }); +}); + +describe('DebateClaimTickerStack', () => { + const resting = [{ window: window(), opacity: 1 }]; + const history = [ + { window: window({ id: 'older', text: 'Congress has ceded its war powers over decades' }), opacity: 1 }, + { window: window(), opacity: 1 }, + ]; + + function renderStack(props: Partial> = {}) { + return render( + + ); + } + + it('shows only the live cards while closed, not the whole backlog', () => { + renderStack(); + + expect(screen.queryByText(/Congress has ceded/)).not.toBeInTheDocument(); + }); + + // The point of the corner is that the backlog is one movement away — no pause, no panel. + it('shows everything said so far when opened', () => { + renderStack({ open: true }); + + expect(screen.getByText(/Congress has ceded/)).toBeInTheDocument(); + }); + + // Opening is the player's call, not the stack's: a card is on screen for a few seconds at a + // time, so a hover target made of the cards would usually not be there to hover. + it('still draws the backlog when no card is live', () => { + renderStack({ cards: [], open: true }); + + expect(screen.getByText(/Congress has ceded/)).toBeInTheDocument(); + }); + + // A caller that gives it no way to be pressed is hover-only, and an empty corner is correct there. + it('draws nothing at all when closed, nothing live, and there is no chip to press', () => { + const { container } = renderStack({ cards: [] }); + + expect(container).toBeEmptyDOMElement(); + }); + + // The way in where there is no hover. Whether it is *drawn* is a media query — `no-hover:flex`, + // which jsdom does not evaluate — so what is checked here is that it exists and counts correctly. + it('rests on a chip naming how many claims are behind the playhead', () => { + renderStack({ cards: [], onTogglePinned: vi.fn() }); + + expect(screen.getByRole('button', { name: 'Show the 2 claims said so far' })).toHaveTextContent('2 claims'); + }); + + it('presses through to the caller, which owns whether the corner is open', () => { + const onTogglePinned = vi.fn(); + renderStack({ cards: [], onTogglePinned }); + + fireEvent.click(screen.getByRole('button', { name: /Show the 2 claims/ })); + + expect(onTogglePinned).toHaveBeenCalledOnce(); + }); + + // A pointer closes the corner by leaving the tile. A tap has nowhere to go, so the way in has to + // double as the way out — and only in that case, or a mouse user gets a control they never need. + it('offers a way back out only when the chip is what opened it', () => { + renderStack({ open: true, pinned: true, onTogglePinned: vi.fn() }); + expect(screen.getByRole('button', { name: 'Hide the claims said so far' })).toBeInTheDocument(); + + cleanup(); + renderStack({ open: true, onTogglePinned: vi.fn() }); + expect(screen.queryByRole('button', { name: /Hide the claims/ })).not.toBeInTheDocument(); + }); + + /** + * The chip sits under the card in the column, so one that stayed up while a claim was live held + * the card 43px higher than it needed to be — the difference, on a phone tile, between a card + * under the speaker's chin and a card across their face. It has nothing to announce in that + * moment either: the claim it would point at is already on screen. + */ + it('gives way to a live claim so the card can sit at the bottom', () => { + renderStack({ onTogglePinned: vi.fn() }); + + expect(screen.getByText(/Supreme Court/)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /claims said so far/ })).not.toBeInTheDocument(); + }); + + // Still the way back out of an opened backlog, live card or not. + it('keeps the chip while the backlog is open', () => { + renderStack({ open: true, pinned: true, onTogglePinned: vi.fn() }); + + expect(screen.getByRole('button', { name: 'Hide the claims said so far' })).toBeInTheDocument(); + }); + + /** + * The open list dissolves into the tile's edge rather than being cut off square, and it may not + * do that with a mask on the box. + * + * A `mask-image` makes its element a Backdrop Root, which leaves `backdrop-filter` on anything + * inside with nothing to sample — masking this box flattened the glass on every card in the list + * until it was scrolled back to the very top, where the mask came off again. The ramp goes on the + * cards, whose own masks do not blind their own backdrop-filter. + */ + it('dissolves its top edge without masking the box the glass cards sit in', () => { + const { container } = renderStack({ open: true }); + const list = container.firstElementChild!.firstElementChild as HTMLElement; + + expect(list.style.maskImage).toBe(''); + expect(list.style.webkitMaskImage).toBe(''); + }); + + /** + * The ramp's two stops, in px. + * + * Read off `--claim-ramp` rather than `mask-image`: the ramp is written to a custom property so + * the card's stylesheet can refuse it on a device with no pointer, where the fade is in the way. + */ + const ramp = (card: HTMLElement) => card.style.getPropertyValue('--claim-ramp'); + const rampStops = (card: HTMLElement) => [...ramp(card).matchAll(/(-?[\d.]+)px/g)].map(m => Number(m[1])); + + /** Three deep, so the ramp has a card above it, one across it, and one below its reach. */ + const deepHistory = [ + { window: window({ id: 'oldest', text: 'Congress has ceded its war powers over decades' }), opacity: 1 }, + { window: window({ id: 'older', text: 'The court has expanded executive deference' }), opacity: 1 }, + { window: window(), opacity: 1 }, + ]; + + /** jsdom lays nothing out, so the list's geometry is stated outright. */ + function layOut(list: HTMLElement) { + const cards = [...list.children] as HTMLElement[]; + cards.forEach((card, index) => { + Object.defineProperty(card, 'offsetTop', { configurable: true, value: index * 100 }); + Object.defineProperty(card, 'offsetHeight', { configurable: true, value: 90 }); + }); + return cards; + } + + const listOf = (container: HTMLElement) => container.firstElementChild!.firstElementChild as HTMLElement; + + /** + * One ramp for the whole list, anchored to its top edge — the construction the Figma frame uses, + * where a single 209×168 mask is positioned per card rather than each card getting its own. + * + * The stops are the frame's: clear for 4.65px, fully opaque at 71.5px. + */ + it('anchors one ramp to the top of the list rather than to each card', () => { + const { container } = renderStack({ open: true, history: deepHistory }); + const list = listOf(container); + const cards = layOut(list); + + fireEvent.scroll(list, { target: { scrollTop: 100 } }); + + // The second card's top is exactly at the edge, so it carries the ramp from its own origin. + expect(rampStops(cards[1])).toEqual([4.65, 71.5]); + // The first has travelled wholly above the edge, where the list's overflow already hides it. + expect(ramp(cards[0])).toBe('none'); + // The third starts below the ramp's reach and is drawn whole. + expect(ramp(cards[2])).toBe('none'); + }); + + // The ramp stays with the edge while the cards move under it. + it('shifts the ramp as the list scrolls under it', () => { + const { container } = renderStack({ open: true, history: deepHistory }); + const list = listOf(container); + const cards = layOut(list); + + fireEvent.scroll(list, { target: { scrollTop: 150 } }); + + // Same card, now 50px above the edge: the whole ramp slides down it by that much. + expect(rampStops(cards[1])).toEqual([54.65, 121.5]); + }); + + /** + * Preston, from the preview: "if you scroll all the way to the top we should take off the blur + * mask". The ramp is there to say there is more above — at the top there is not, and dissolving + * the oldest claim is the one moment it works against the reader who scrolled back to find it. + */ + it('takes the ramp off once the list is scrolled to the very top', () => { + const { container } = renderStack({ open: true, history: deepHistory }); + const list = listOf(container); + const cards = layOut(list); + + fireEvent.scroll(list, { target: { scrollTop: 100 } }); + expect(ramp(cards[1])).not.toBe('none'); + + fireEvent.scroll(list, { target: { scrollTop: 0 } }); + + for (const card of cards) expect(ramp(card)).toBe('none'); + }); + + // The ramp is written to the nodes, and the newest card survives the close. + it('takes the ramp off again when the corner closes', () => { + const { container, rerender } = renderStack({ open: true, history: deepHistory }); + const list = listOf(container); + const cards = layOut(list); + + fireEvent.scroll(list, { target: { scrollTop: 100 } }); + expect(ramp(cards[1])).not.toBe('none'); + + rerender( + + ); + + for (const card of [...list.children] as HTMLElement[]) expect(ramp(card)).toBe('none'); + }); + + // The gaps between cards are holes in the list, and the video behind is one big play/pause + // button. A thumb aiming at a card and missing by a few px should not stop the debate. + it('does not toggle playback when a gap between cards is clicked', () => { + const onToggle = vi.fn(); + const { container } = render( +
+ +
+ ); + + // The scrolling list itself — the element the cards sit in, and the only part of the corner + // that takes pointer events across the gaps between them. + fireEvent.click(container.firstElementChild!.firstElementChild!.firstElementChild!); + + expect(onToggle).not.toHaveBeenCalled(); + }); + + /** + * A reader who has scrolled back is reading. The debate keeps talking while they do, and the list + * used to jump to the newest claim every time one arrived — which took the sentence they were + * halfway through off the screen and read as the list closing and starting over. + */ + it('leaves a reader where they are when a new claim arrives', () => { + const { container, rerender } = renderStack({ open: true }); + const list = container.firstElementChild!.firstElementChild as HTMLElement; + + // jsdom lays nothing out, so the scroll geometry is stated outright: a list twice its own + // height, scrolled to the top of it. + Object.defineProperty(list, 'scrollHeight', { configurable: true, value: 400 }); + Object.defineProperty(list, 'clientHeight', { configurable: true, value: 200 }); + fireEvent.scroll(list, { target: { scrollTop: 0 } }); + + rerender( + + ); + + expect(list.scrollTop).toBe(0); + // Still added, and at the bottom — it is only the view that stays put. + expect(screen.getByText(/And one more thing/)).toBeInTheDocument(); + }); + + // The other half of the same bargain: someone watching the newest claim keeps watching it. + it('follows the newest claim for a reader already at the bottom', () => { + const { container, rerender } = renderStack({ open: true }); + const list = container.firstElementChild!.firstElementChild as HTMLElement; + + Object.defineProperty(list, 'scrollHeight', { configurable: true, value: 400 }); + Object.defineProperty(list, 'clientHeight', { configurable: true, value: 200 }); + fireEvent.scroll(list, { target: { scrollTop: 200 } }); + + rerender( + + ); + + expect(list.scrollTop).toBe(400); + }); + + // Hover is not available to a keyboard, and the backlog is content rather than decoration, so + // focus reaching the stack has to open it the way the pointer does. + it('reports focus entering and leaving so the player can open it', async () => { + const onFocusChange = vi.fn(); + const { container } = renderStack({ onFocusChange }); + + // A keyboard reaches this stack by landing on the first control inside a card, and React's + // onFocus bubbles from there to the list. Which control that is belongs to the card's own + // layout, so this asserts only that focus arrived somewhere inside. + await userEvent.tab(); + const focused = document.activeElement as HTMLElement; + expect(container.firstElementChild?.contains(focused)).toBe(true); + expect(onFocusChange).toHaveBeenLastCalledWith(true); + + fireEvent.blur(focused); + expect(onFocusChange).toHaveBeenLastCalledWith(false); + }); + + /** + * The premise behind the player clearing this itself: a stack that is removed reports nothing. + * + * Browsers fire no `blur` or `focusout` for a focused element taken out of the document, and this + * handler is the only thing that ever says focus left. So the stack going away with a keyboard + * inside it — the debate ending, or the tile scrolling out of the preload window — leaves the + * player's `focusedSlot` latched on a stack that no longer exists. Asserting the *absence* here + * is what makes the cleanup in `debate-feed-player.tsx` necessary rather than belt-and-braces. + */ + it('reports nothing when it is removed with focus still inside it', async () => { + const onFocusChange = vi.fn(); + const { unmount } = renderStack({ onFocusChange }); + + await userEvent.tab(); + expect(onFocusChange).toHaveBeenLastCalledWith(true); + + unmount(); + + expect(onFocusChange).not.toHaveBeenCalledWith(false); + }); + + /** + * A click inside a live card is not a request for the backlog. + * + * The player opens the backlog on focus, and draws it narrower than a live card. Any focus + * counting meant clicking the expand toggle on a clamped claim swapped the card for the list + * mid-read, and re-laid the claim out at the list's width — so the reader asked for the rest of + * the sentence in front of them and got it in a different column. + */ + it('does not report focus when a click lands inside it', async () => { + forceClampedOverflow(); + const onFocusChange = vi.fn(); + renderStack({ onFocusChange }); + + await userEvent.click(screen.getByTitle('Show the whole claim')); + + expect(screen.getByTitle('Show less')).toHaveAttribute('aria-expanded', 'true'); + expect(onFocusChange).not.toHaveBeenCalledWith(true); + }); + + /** + * The chip is a sibling of the scroll box, so a boundary drawn around the box alone reported + * focus *gone* the moment a keyboard tabbed from the last card out to the chip. The player then + * closed the backlog, `showChip` went false with a live card present, and the chip unmounted + * mid-tab — dropping focus to the body, with the one control that reopens the list now missing. + */ + it('keeps reporting focus when a keyboard tabs from a card out to the chip', async () => { + const onFocusChange = vi.fn(); + renderStack({ onFocusChange, open: true, pinned: true, onTogglePinned: vi.fn() }); + const user = userEvent.setup(); + + // Into the stack, then on until the chip has it. The cards' own controls come first. + const chip = screen.getByRole('button', { name: /claims said so far|Hide the claims/i }); + for (let i = 0; i < 12 && document.activeElement !== chip; i += 1) await user.tab(); + + expect(chip).toHaveFocus(); + expect(onFocusChange).toHaveBeenLastCalledWith(true); + expect(onFocusChange).not.toHaveBeenCalledWith(false); + }); + + // Same for the thumbs, which is the other thing a pointer comes to a live card to do. + it('does not report focus when a thumb is clicked', async () => { + const onFocusChange = vi.fn(); + renderStack({ onFocusChange }); + + await userEvent.click(screen.getByLabelText('Agree')); + + expect(onFocusChange).not.toHaveBeenCalledWith(true); + }); +}); + +/** + * One gate decides what the ticker will offer, and all three offers read from it. + * + * A claim needs a speaker the participant list recognises and a space to answer in before a card + * can be drawn for it. The card layer has always enforced that; the scrubber and the chip did not, + * so both pointed at claims that could never appear. These assert the three agree. + */ +describe('useDebateClaimTicker', () => { + const DEBATE_SPACE = '52c7ae149838b6d47ce0f3b2a5974546'; + const SPEAKER_SPACE = '4582fbbee28a16589154f7e36f1ee3c5'; + + const debate = { + id: 'debate-1', + claim: { space_id: DEBATE_SPACE }, + participants: [SPEAKER], + } as unknown as Parameters[0]; + + /** A claim the matcher never has to place, so these tests only exercise the gate. */ + const published = (overrides: Partial = {}) => + claim({ publishedTiming: { startMs: 134_600, endMs: 143_140 }, ...overrides }); + + /** `playheadMs` past every claim's end, so the backlog holds whatever the gate let through. */ + function renderTicker(claims: TimedClaim[], blocks: Array<{ id: string; authorSpaceId: string | null }>) { + mocks.transcriptClaims = { all: claims, blocks, byAuthorSpaceId: new Map() }; + return renderHook(() => useDebateClaimTicker(debate, { playheadMs: 200_000, timelineMs: 300_000, enabled: true })) + .result.current; + } + + it('offers a claim whose speaker and space both resolve', () => { + const ticker = renderTicker([published()], [{ id: 'block-1', authorSpaceId: SPEAKER_SPACE }]); + + expect(ticker.markers.map(marker => marker.id)).toEqual(['claim-1']); + expect(ticker.historyBySlot.get(1)).toHaveLength(1); + }); + + // Attribution and the participant list can disagree. The card refuses to park such a claim over + // whichever face is nearer, so nothing else should point at it either. + it('offers nothing for a claim whose speaker is not a participant', () => { + const ticker = renderTicker([published()], [{ id: 'block-1', authorSpaceId: null }]); + + expect(ticker.markers).toEqual([]); + expect(ticker.historyBySlot.size).toBe(0); + expect(ticker.cardsBySlot.size).toBe(0); + }); + + /** + * Two claim entities can carry the same text and the same published moment — measured at 11 + * claims in one corpus debate, apparently published twice. Everything downstream then doubles: + * two identical cards in the backlog, a chip counting both, and two markers at one spot where the + * later covers the earlier, so the first cannot be reached with a pointer. + */ + it('shows a claim once when two entities carry the same text at the same moment', () => { + const ticker = renderTicker( + [published(), published({ id: 'claim-2' })], + [{ id: 'block-1', authorSpaceId: SPEAKER_SPACE }] + ); + + expect(ticker.markers.map(m => m.id)).toEqual(['claim-1']); + expect(ticker.historyBySlot.get(1)).toHaveLength(1); + }); + + // A debater who repeats themselves later has said something new, so both moments keep a card. + it('keeps both when the same words are said at a different moment', () => { + const ticker = renderTicker( + [published(), published({ id: 'claim-2', publishedTiming: { startMs: 200_000, endMs: 204_000 } })], + [{ id: 'block-1', authorSpaceId: SPEAKER_SPACE }] + ); + + expect(ticker.markers.map(m => m.id).sort()).toEqual(['claim-1', 'claim-2']); + }); + + /** + * A disabled ticker still reads a warm cache — the feed card and the explore card fetch the same + * query ungated. The cards and backlog already refused to build from it; the markers did not, so + * an inactive feed card could draw a hash that seeks to a claim it will never show. + */ + it('offers nothing at all while switched off', () => { + mocks.transcriptClaims = { + all: [published()], + blocks: [{ id: 'block-1', authorSpaceId: SPEAKER_SPACE }], + byAuthorSpaceId: new Map(), + }; + const ticker = renderHook(() => + useDebateClaimTicker(debate, { playheadMs: 200_000, timelineMs: 300_000, enabled: false }) + ).result.current; + + expect(ticker.markers).toEqual([]); + expect(ticker.cardsBySlot.size).toBe(0); + expect(ticker.historyBySlot.size).toBe(0); + }); + + // `DebateClaimTickerCard` returns null without a space — there is nowhere to record an answer. + it('offers nothing for a claim with no space to answer in', () => { + const ticker = renderTicker([published({ spaceId: null })], [{ id: 'block-1', authorSpaceId: SPEAKER_SPACE }]); + + expect(ticker.markers).toEqual([]); + expect(ticker.historyBySlot.size).toBe(0); + }); +}); + +/** + * The scrubber hashes are 2px wide and sit above the range input, so their hit area is both the + * accessibility problem and the thing that can steal a drag or a neighbour's tap. The width is + * computed per marker rather than set in CSS, so it is worth holding. + */ +describe('markerHitWidth', () => { + const marker = (id: string, fraction: number): ClaimMarker => ({ + id, + text: `Claim ${id}`, + atMs: fraction * 100_000, + seekMs: fraction * 100_000 + 250, + fraction, + count: 1, + }); + + const widths = (markers: ClaimMarker[]) => markers.map((_, index) => markerHitWidth(markers, index)); + + // Nothing to crowd it, so it takes the ceiling — which is 12 rather than WCAG's 24 because these + // sit over the scrub bar, and whatever they cover is a place a drag cannot start. + it('gives the only marker on the bar the full hit width', () => { + expect(widths([marker('a', 0.5)])).toEqual(['12px']); + }); + + /** + * The clamp that stops a wider target covering the next claim: measured over the corpus, 34% of + * adjacent pairs sit closer than 24px on a phone. Two markers 1% apart can each be 1% wide — they + * meet, and neither reaches the other's centre — but no wider. + */ + it('never lets a target reach past its nearest neighbour', () => { + expect(widths([marker('a', 0.5), marker('b', 0.51)])).toEqual([ + 'clamp(2px, 1.000%, 12px)', + 'clamp(2px, 1.000%, 12px)', + ]); + }); + + // Each marker takes its *nearest* neighbour, not the one before it. The middle marker here is + // crowded on one side only; the far one is crowded by nothing and hits the ceiling. + it('measures the nearer of the two neighbours', () => { + expect(widths([marker('a', 0.1), marker('b', 0.11), marker('c', 0.9)])).toEqual([ + 'clamp(2px, 1.000%, 12px)', + 'clamp(2px, 1.000%, 12px)', + 'clamp(2px, 79.000%, 12px)', + ]); + }); + + /** + * The floor, on an input `claimMarkers` no longer produces. + * + * Two markers on one point overlap exactly — same `left`, same width — so the later covers the + * earlier and one claim cannot be pressed at all. That is fixed at the producer: coincident + * claims collapse into a single hash before they get here, and + * `claim-ticker.test.ts` holds that. This holds the floor anyway, because `markerHitWidth` is + * exported and pure, and a 2px button that can be pressed beats a 0px one that cannot. + */ + it('keeps a floor under markers that land on the same moment', () => { + expect(widths([marker('a', 0.5), marker('b', 0.5)])).toEqual([ + 'clamp(2px, 0.000%, 12px)', + 'clamp(2px, 0.000%, 12px)', + ]); + }); +}); + +describe('ClaimScrubberMarkers', () => { + const marker = (id: string, fraction: number): ClaimMarker => ({ + id, + text: `Claim ${id}`, + atMs: fraction * 100_000, + seekMs: fraction * 100_000 + 250, + fraction, + count: 1, + }); + + it('seeks into the claim window rather than to the hash it draws', () => { + const onSeek = vi.fn(); + render(); + + fireEvent.click(screen.getByLabelText('Jump to: Claim a')); + + expect(onSeek).toHaveBeenCalledWith(50_250); + }); + + // The hash is drawn by a pseudo-element so the target can grow around it without the mark + // growing too. Nothing here can read a pseudo-element, so this holds the button itself empty. + it('draws its hash without a child element the target could inherit size from', () => { + const { container } = render(); + + expect(container.querySelector('button')?.children).toHaveLength(0); + }); +}); diff --git a/apps/web/core/debates/browse/debate-claim-ticker.tsx b/apps/web/core/debates/browse/debate-claim-ticker.tsx new file mode 100644 index 0000000000..5289f66ad5 --- /dev/null +++ b/apps/web/core/debates/browse/debate-claim-ticker.tsx @@ -0,0 +1,1048 @@ +'use client'; + +import * as React from 'react'; + +import cx from 'classnames'; + +import type { Debate, DebateClaim, DebateParticipant } from '~/core/debates/api'; +import { + type ClaimMarker, + type StackedCard, + type TickerWindow, + backlogWindows, + claimHistory, + claimMarkers, + tickerStack, + tickerWindows, +} from '~/core/debates/claim-ticker'; +import { claimsInSpokenOrder } from '~/core/debates/claim-timing'; +import { useDebateClaimsBySpaces } from '~/core/debates/hooks'; +import { orderedParticipants, speakerLabel } from '~/core/debates/playback-utils'; +import { useClaimTimings } from '~/core/debates/use-claim-timings'; +import { useDebateTranscriptClaims } from '~/core/debates/use-debate-transcript-claims'; +import { uuidToHex } from '~/core/id/normalize'; +import { ENTITY_RESPONSE_COPY } from '~/core/responses/entity-response'; +import { useQueryEntities } from '~/core/sync/use-store'; +import type { Entity } from '~/core/types'; + +import { Avatar } from '~/design-system/avatar'; +import { ChevronDown } from '~/design-system/icons/chevron-down'; +import { ChevronUp } from '~/design-system/icons/chevron-up'; +import { InfoSmall } from '~/design-system/icons/info-small'; +import { ThumbDown } from '~/design-system/icons/thumb-down'; +import { ThumbUp } from '~/design-system/icons/thumb-up'; + +import { useLineClampOverflow } from './line-clamp-overflow'; +import { useDebateClaimResponse } from './use-debate-claim-response'; +import { useOpenDebaterProfile } from './use-open-debater-profile'; + +type DebateTicker = { + /** + * The cards to draw right now, oldest first, keyed by the participant slot that said them. + * + * One corner per debater rather than one for the player. A viewer watching a debate is looking at + * whoever is talking, so that is where their claims should appear — a single shared corner asks + * the eye to leave the speaker to read what the speaker is saying. + */ + cardsBySlot: Map; + /** + * Every claim each debater has said so far, oldest first — what their corner opens into. + * + * Bounded by the playhead, so it is a record of what has been said rather than a table of + * contents for what is coming. + */ + historyBySlot: Map; + /** Every precisely-placed claim, for the scrubber. */ + markers: ClaimMarker[]; + /** Which way the viewer answered each claim this session. */ + answers: ReadonlyMap; + onAnswered: (claimId: string, position: boolean | null) => void; + /** Per-claim lookups, hoisted so the card and the end-of-debate stack share one batch. */ + rowsByClaimId: Map; + entitiesByClaimId: Map; + /** Claim id → the debater who said it, for the avatar and name the live card wears. */ + participantByClaimId: Map; +}; + +/** + * Everything the live claim layer needs for one debate. + * + * Both reads are shared cache entries rather than new requests: the claims come back on the same + * key the feed's count badge and the Claims panel already use, and the transcript on the same key + * `useDebatePlayback` fetches for subtitles. On a card that is playing, this costs nothing. + */ +export function useDebateClaimTicker( + debate: Debate, + { + playheadMs, + /** The debate's own timeline, which is what the scrubber track measures. */ + timelineMs, + enabled, + }: { playheadMs: number; timelineMs: number; enabled: boolean } +): DebateTicker { + const { claims } = useDebateTranscriptClaims(debate.id, debate.claim.space_id, enabled); + const { timings } = useClaimTimings(debate.id, claims, enabled); + + const [answers, setAnswers] = React.useState>(() => new Map()); + + // Recorded, not acted on. Answering used to also dismiss the card, which took the side the + // viewer had just chosen off the screen before they saw it land; the filled icon is the + // acknowledgement now, and the card stays until a newer claim displaces it. The end-of-debate + // card still reads this to skip what has already been answered. + const onAnswered = React.useCallback((claimId: string, position: boolean | null) => { + setAnswers(current => { + // Identity is the bail-out: React skips the update when the state comes back unchanged, which + // is what lets every card report on mount without a re-render apiece. + if (position === null) { + if (!current.has(claimId)) return current; + const next = new Map(current); + next.delete(claimId); + return next; + } + if (current.get(claimId) === position) return current; + return new Map(current).set(claimId, position); + }); + }, []); + + // Attribution rides the *block*, not the claim: a claim's own space is the debate's publication + // space, which both debaters share. The block's `Authors` relation points at the speaker's + // personal space, which is the id the participant list keys on. + const participantByClaimId = React.useMemo(() => { + const bySpace = new Map(); + for (const participant of orderedParticipants(debate)) { + bySpace.set(uuidToHex(participant.profile_space_id), participant); + } + + const byBlock = new Map(); + for (const block of claims.blocks) { + const speaker = block.authorSpaceId ? bySpace.get(uuidToHex(block.authorSpaceId)) : undefined; + if (speaker) byBlock.set(block.id, speaker); + } + + const speakers = new Map(); + for (const claim of claims.all) { + const speaker = byBlock.get(claim.blockId); + if (!speaker) continue; + speakers.set(claim.id, speaker); + } + return speakers; + }, [claims.all, claims.blocks, debate]); + + const timedClaims = React.useMemo(() => claimsInSpokenOrder(claims.all, timings), [claims.all, timings]); + + /** + * One gate for everything this surface offers, so the three ways it points at a claim agree. + * + * A claim needs two things before it can be drawn: a speaker the participant list recognises, so + * the card can attribute it, and a space to answer in — without either, `DebateClaimTickerCard` + * renders nothing. Applied here rather than at each consumer because the consumers were drifting: + * cards dropped these claims, while the scrubber drew a clickable hash for them and the chip + * counted them, so both promised a card that could never appear. A hash nothing is behind is the + * same broken promise {@link claimMarkers} already turns low-confidence matches away to avoid. + * + * Neither of those trips in the corpus today — measured over all 854 published claims, every one + * has a space and an authored block. They are for the debates recorded after this one. + * + * **Empty while the ticker is switched off.** `useDebateTranscriptClaims` is gated on `enabled`, + * but the feed card and the explore card fetch the same query ungated, so a disabled ticker reads + * a full claim list out of a warm cache. `groupBySlot` already refused to build cards from it; + * the markers did not, so an inactive feed card could draw a clickable hash that seeks to a claim + * whose card and backlog are both deliberately empty. One gate now, not two half-gates. + * + * **The third rule is about what a viewer can tell apart.** Two claim entities can carry the same + * text *and* the same published moment, and then everything downstream doubles: two identical + * cards in the backlog, a chip counting them twice, and two markers at one spot where the later + * one covers the earlier so the first is unreachable by pointer. Measured: 11 such claims, all in + * one debate, whose claims appear to have been published twice; and zero cases of the same text + * at *different* moments, which is why the key is text **and** moment rather than text alone — a + * debater who genuinely repeats themselves later has said something new, and keeps their card. + * + * What the collapse cannot fix is that the twin is a separate entity with its own responses, so + * an answer lands on whichever copy survived here. That is an argument for de-duplicating on + * publish, not for showing the same sentence twice. + */ + const renderableClaims = React.useMemo(() => { + if (!enabled) return []; + + const seen = new Set(); + return timedClaims.filter(claim => { + if (claim.spaceId === null || !participantByClaimId.has(claim.id)) return false; + + const key = `${claim.timing?.endMs ?? 'unplaced'} ${claim.text.trim().toLowerCase()}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + }, [enabled, participantByClaimId, timedClaims]); + + // Two lists, because the live layer and the backlog answer different questions — see + // `backlogWindows`. Cards and markers assert a moment; the backlog only says "already said". + const windows = React.useMemo(() => tickerWindows(renderableClaims), [renderableClaims]); + const backlog = React.useMemo(() => backlogWindows(renderableClaims), [renderableClaims]); + const markers = React.useMemo(() => claimMarkers(renderableClaims, timelineMs), [renderableClaims, timelineMs]); + + // One batch for every claim, the way the panel does it, so the live card and the end-of-debate + // stack never issue a lookup per claim as they mount. + const claimIds = React.useMemo(() => claims.all.map(claim => claim.id), [claims.all]); + const { entities } = useQueryEntities({ + where: { id: { in: claimIds } }, + first: claimIds.length || 1, + enabled: enabled && claimIds.length > 0, + }); + const entitiesByClaimId = React.useMemo(() => { + const map = new Map(); + for (const entity of entities) map.set(entity.id, entity); + return map; + }, [entities]); + + /** + * Empty while this ticker is switched off, which is not the same as having no claims. + * + * `useDebateTranscriptClaims` is gated on `enabled`, but the feed card and the explore card both + * fetch the same query *ungated* — so the cache is warm and a disabled ticker still reads a full + * claim list out of it. Handing that to `useDebateClaimsBySpaces`, which has no gate of its own, + * started authenticated row queries and a gateway subscription for every mounted feed item rather + * than the active and preloaded pair. + */ + const rowGroups = React.useMemo(() => { + if (!enabled) return []; + + const bySpace = new Map(); + for (const claim of claims.all) { + if (!claim.spaceId) continue; + const ids = bySpace.get(claim.spaceId); + if (ids) ids.push(claim.id); + else bySpace.set(claim.spaceId, [claim.id]); + } + return [...bySpace].map(([spaceId, ids]) => ({ spaceId, claimIds: ids })); + }, [enabled, claims.all]); + + const rowsQuery = useDebateClaimsBySpaces(rowGroups); + const rowsByClaimId = React.useMemo(() => { + const map = new Map(); + for (const row of rowsQuery.claims) map.set(row.claim_entity_id, row); + return map; + }, [rowsQuery.claims]); + + // One stack per debater, over their own tile. A claim whose speaker could not be resolved — + // attribution and the participant list can disagree — never reaches here, because + // `renderableClaims` has already turned it away rather than letting it be parked over whichever + // half: putting a claim over the wrong face is the misquote this whole layer is careful about, + // and now that the corner itself attributes, getting it wrong is louder. The lookup stays because + // it is how the slot is read, and it is the one place the two could fall out of step. + const groupBySlot = React.useCallback( + (source: TickerWindow[]) => { + const bySlot = new Map(); + // Belt and braces: `renderableClaims` is empty while disabled, so `source` already is. Kept + // because this is a public-ish shape and a future caller could pass windows from elsewhere. + if (!enabled) return bySlot; + + for (const window of source) { + const slot = participantByClaimId.get(window.claim.id)?.participant_slot; + if (slot === undefined) continue; + const existing = bySlot.get(slot); + if (existing) existing.push(window); + else bySlot.set(slot, [window]); + } + return bySlot; + }, + [enabled, participantByClaimId] + ); + + const liveBySlot = React.useMemo(() => groupBySlot(windows), [groupBySlot, windows]); + const backlogBySlot = React.useMemo(() => groupBySlot(backlog), [groupBySlot, backlog]); + + const cardsBySlot = React.useMemo(() => { + const cards = new Map(); + for (const [slot, slotWindows] of liveBySlot) cards.set(slot, tickerStack(slotWindows, playheadMs)); + return cards; + }, [liveBySlot, playheadMs]); + + const historyBySlot = React.useMemo(() => { + const history = new Map(); + for (const [slot, slotWindows] of backlogBySlot) history.set(slot, claimHistory(slotWindows, playheadMs)); + return history; + }, [backlogBySlot, playheadMs]); + + return { + cardsBySlot, + historyBySlot, + markers, + answers, + onAnswered, + rowsByClaimId, + entitiesByClaimId, + participantByClaimId, + }; +} + +/** + * The stack's dissolve, taken from the mask the Figma frame actually ships. + * + * That mask is a 209×168 alpha rect filled with a vertical gradient running `y1=0 → y2=71.5`, with + * stops at `0.065` (white, opacity 0) and `1` (white): nothing shows for the first 4.65px, the ramp + * climbs from there to fully opaque at 71.5px, and the remaining ~96px of the rect is solid. The + * frame positions that one rect per card — `mask-position 0,-71px` on the newest, `0,18px` on the + * one above — rather than giving each card a gradient of its own, which is the same thing as + * anchoring the ramp to the top of the stack region and letting each card carry its slice. + * + * Reproduced per card for a second reason as well: a `mask-image` makes its element a Backdrop + * Root, so a mask on the scroll box leaves any `backdrop-filter` inside it nothing to sample. An + * element's own mask does not blind its own — measured in Chrome, a card over hard stripes reads + * 160 contrast unmasked, 178 masked itself, and 249 with the mask on its parent. + */ +const HISTORY_EDGE_OPAQUE_PX = 71.5; +/** The lead-in: the gradient's first stop sits 6.5% down its 71.5px, and nothing shows above it. */ +const HISTORY_EDGE_CLEAR_PX = 4.65; + +/** + * The claim card that rises over the video as it is said. + * + * A translucent dark card in the player's bottom-right corner, stacked upward: the newest arrives + * at the bottom and earlier ones ride up and dissolve, which is the shape of a chat rather than a + * dialog. It never pauses the video or opens anything — the only thing that interrupts playback is + * the sign-in prompt, which is the app's standard prompt and only appears if the viewer presses a + * thumb while signed out. + */ +export function DebateClaimTickerCard({ + window, + opacity = 1, + speaker = null, + row, + entity, + onAnswered, +}: { + window: TickerWindow; + /** Driven by the playhead, so a scrub lands on the right strength rather than mid-animation. */ + opacity?: number; + /** Who said it. The card names them, so it no longer has to sit over their tile to attribute. */ + speaker?: DebateParticipant | null; + row: DebateClaim | null; + entity: Entity | null; + onAnswered: (claimId: string, position: boolean | null) => void; +}) { + const { claim } = window; + + if (!claim.spaceId) return null; + + return ( +
event.stopPropagation()} + // Left off entirely at full strength, so the open list's edge fade — which writes this + // property straight to the node on scroll — is not overwritten on the next render. + style={opacity === 1 ? undefined : { opacity }} + /** + * `rgba(21,21,21,0.3)`, 8px radius, 12px padding, 6px between the header and the claim, over a + * 44px backdrop blur. + * + * Every value but the blur is read straight off the frame. The blur is fitted, because the + * Dev Mode export does not carry it — there is no `filter` or `backdrop` anywhere in that + * file, and taking the omission at face value is how this card ended up flat. + * + * What gives it away is the render itself. Down the card's right edge the video outside runs + * 176 → 54 as the tile's scrim ramps to black, while inside the card the same column barely + * moves, 118 → 98 — and at the bottom the card is *brighter* than the backdrop it covers, + * which no amount of dark translucent fill can do. Only a blur can, by averaging in the + * brightness above. Rebuilding the tile from the same still and sweeping the radius puts the + * best fit at 44px: mean error 5.7/255 against the frame, where no blur at all scores 20.0. + * + * `shrink-0` so the open list scrolls a full-height card rather than compressing it to fit. + * + * The ramp arrives as `--claim-ramp`, which {@link paintEdgeFade} only ever sets while the + * backlog is open — a live card is never dissolved, on any device. + * + * It did come off entirely on touch for a while, because an expanded claim could end up half + * dissolved with no way to bring it back. Two things since have made that the wrong cure: the + * ramp lifts at the top of the list, which is where a reader who has scrolled back is, and + * the open list is narrower than the live card on a phone now, so the fade reads as the edge + * of a list rather than as damage to the claim you are reading. + */ + className="pointer-events-auto flex w-full shrink-0 flex-col gap-1.5 rounded-lg bg-[#151515]/30 [mask-image:var(--claim-ramp,none)] p-3 backdrop-blur-[44px] [-webkit-mask-image:var(--claim-ramp,none)]" + > + + +
+ ); +} + +/** + * Two lines of a claim before the card starts covering the face saying it. + * + * Three was the frame's, and the arithmetic changed when the card got wider. Measured over all 854 + * published claims: three lines at 260px shows 66% of them whole, two lines at 360px shows 62% — + * near enough the same reading, in a card 17px shorter. Height is the expensive dimension here, + * because the speaker's face is in the middle of the tile and the card grows up towards it, while + * width runs along the bottom where there is far less to cover. + */ +const TICKER_CLAMP_LINES = 2; + +/** + * The claim itself, clamped to three lines, and expandable in place when there is more. + * + * A claim runs to a sentence or two and three lines of a 209px card is often not all of it. The + * alternative to expanding here is "go and find it in the panel", which means leaving the debate + * to read a sentence that is on screen — so the text opens where it is, and closes again the same + * way. + * + * Only interactive when it is actually truncated. A button that visibly does nothing is worse than + * no button, and most short claims fit. + */ +function TickerClaimText({ text }: { text: string }) { + // In state rather than a ref: wrapping the text in a button below remounts this node, and the + // measurement has to follow it. See `useLineClampOverflow`. + const [textElement, setTextElement] = React.useState(null); + const [expanded, setExpanded] = React.useState(false); + + // A new claim in the same card slot starts collapsed rather than inheriting the last one's state. + React.useEffect(() => setExpanded(false), [text]); + + const overflowing = useLineClampOverflow(textElement, { + maxLines: TICKER_CLAMP_LINES, + enabled: !expanded, + contentKey: text, + }); + + const body = ( + + {text} + + ); + + if (!overflowing && !expanded) return body; + + return ( + + ); +} + +/** + * The claim cards in the player's bottom-right corner, oldest at the top. + * + * Anchored in the corner rather than centred over the video: a card in the middle reads as a + * dialog demanding an answer, and it covers the face of the person making the argument. + * + * Two modes. Closed, it is the live layer: a claim appears as it is said, lingers, and goes, so the + * corner is empty most of the time. Open — the viewer's pointer is anywhere over the player, or + * they have tabbed into the stack — it becomes the whole of what has been said so far, scrollable, + * newest at the bottom. That is the Twitch-chat bargain: the corner stays a corner while you are + * watching, and the backlog is one movement away without pausing the video or opening a panel. + * + * `open` is the caller's, because the pointer has to be able to open this from anywhere over the + * video — including when nothing is on screen here to point at, which is most of the time. + */ +export function DebateClaimTickerStack({ + cards, + history, + open = false, + pinned = false, + onTogglePinned, + onFocusChange, + participantByClaimId, + rowsByClaimId, + entitiesByClaimId, + onAnswered, +}: { + cards: StackedCard[]; + /** Everything said so far. Falls back to the live cards where a caller has no history. */ + history?: StackedCard[]; + /** Show the backlog rather than the live cards. */ + open?: boolean; + /** Open because the chip was pressed rather than because the pointer is over the tile. */ + pinned?: boolean; + /** Pressing the chip. Without it the corner has no chip and is hover-only. */ + onTogglePinned?: () => void; + /** Keyboard focus entering or leaving the stack, which opens it the way the pointer does. */ + onFocusChange?: (focused: boolean) => void; + participantByClaimId?: Map; + rowsByClaimId: Map; + entitiesByClaimId: Map; + onAnswered: (claimId: string, position: boolean | null) => void; +}) { + const scrollRef = React.useRef(null); + + const shown = open ? (history ?? cards) : cards; + const backlog = history ?? cards; + + /** + * Whether the list is sitting at its newest claim, and so should follow the next one down. + * + * A ref rather than state: it is read inside effects and never drawn, so tracking it in state + * would re-render the list on every scroll frame to change nothing on screen. + */ + const followingLatest = React.useRef(true); + + /** + * Dissolve the top of the open list — see {@link HISTORY_EDGE_OPAQUE_PX}. + * + * Each card carries the slice of the ramp that falls across it, found by shifting the gradient's + * stops by how far the card's own top sits from the top of the list. A card below the ramp + * entirely gets no mask at all, which is nearly all of them. + * + * Written straight to the node rather than through state, because this runs on every scroll frame + * and re-rendering the list to change a gradient on one card is work the browser should not be + * asked to do while a finger is moving. React never sets `mask-image` on these cards, so there is + * no tug of war over the property. + */ + const paintEdgeFade = React.useCallback(() => { + const element = scrollRef.current; + if (!element) return; + const atTop = element.scrollTop <= 1; + for (const card of Array.from(element.children) as HTMLElement[]) { + // The card's top edge, measured from the top of what the list is showing. Negative once the + // card has started to travel up past it. + const fromEdge = card.offsetTop - element.scrollTop; + const clear = -fromEdge + HISTORY_EDGE_CLEAR_PX; + const opaque = -fromEdge + HISTORY_EDGE_OPAQUE_PX; + // Nothing to paint on a card the ramp does not reach, nor on one that has travelled wholly + // above the edge, where the list's own overflow has it already. + const untouched = opaque <= 0 || fromEdge + card.offsetHeight < 0; + // `open` is checked here rather than by the callers, because the closed corner is a + // content-sized box whose top *is* the card's top — the ramp would dissolve the live claim. + // + // `atTop` because a reader who has scrolled the list as far back as it goes is looking at the + // oldest claim, and dissolving what they just scrolled to is the one moment the ramp works + // against them. It is there to say "more above"; at the top there is not. + const ramp = + !open || atTop || untouched ? '' : `linear-gradient(to bottom, transparent ${clear}px, #000 ${opaque}px)`; + // Through a custom property rather than `mask-image` itself: the card declares the standard + // and `-webkit-` masks together in its own className, so one variable drives both and the + // declarations stay with the rest of the card's styling. + card.style.setProperty('--claim-ramp', ramp || 'none'); + } + }, [open]); + + const onScroll = React.useCallback(() => { + const element = scrollRef.current; + if (element) { + // A few px of slack: a list scrolled to the end is often a fraction of a pixel short of it. + followingLatest.current = element.scrollHeight - element.scrollTop - element.clientHeight < 8; + } + paintEdgeFade(); + }, [paintEdgeFade]); + + // Opens on the most recent claim — scrolling *up* from there is the "go back through it" this + // exists for. Closing repaints too, and has to: the ramp is written to the card nodes, and the + // newest card survives the close, so a stale slice of gradient would follow it back out. + React.useEffect(() => { + const element = scrollRef.current; + if (!element) return; + if (open) { + followingLatest.current = true; + element.scrollTop = element.scrollHeight; + } + paintEdgeFade(); + }, [open, paintEdgeFade]); + + /** + * The list also re-follows the bottom when its geometry changes under it, not only when a claim + * arrives. Two ways that happens, both reported from the preview: + * + * The scrubber appearing lifts the corner by `pb-5`, which shortens the scroll box. A shorter box + * raises the maximum scrollTop, so a list that was resting at the bottom is suddenly short of it + * and the newest card is cut off by the scrubber. + * + * Expanding a clamped claim makes its card taller. The card grows downward, past the bottom of + * the box, so the reader taps to read the rest of a claim and the rest of it goes under the edge. + * + * Watching the children as well as the box is what catches the second one — the box does not + * change size there, only its content does. + */ + React.useEffect(() => { + const element = scrollRef.current; + if (!element || typeof ResizeObserver === 'undefined') return; + + const observer = new ResizeObserver(() => { + if (open && followingLatest.current) element.scrollTop = element.scrollHeight; + paintEdgeFade(); + }); + observer.observe(element); + for (const card of Array.from(element.children)) observer.observe(card); + return () => observer.disconnect(); + }, [open, shown.length, paintEdgeFade]); + + // A claim arriving while the list is open lands at the bottom, and the list follows it only if + // the reader was already down there. They open this to read back through what was said, and a + // debate keeps talking while they do: yanking the view to the newest card took the sentence they + // were halfway through off the screen, which read as the list closing and starting over. + React.useEffect(() => { + const element = scrollRef.current; + if (!element) return; + if (open && followingLatest.current) element.scrollTop = element.scrollHeight; + paintEdgeFade(); + }, [open, shown.length, paintEdgeFade]); + + /** + * The chip is the only thing on screen saying the backlog exists at all, and on a touch screen it + * is the only way to reach it: there is no hover, and the video behind is one large play/pause + * button, so the corner cannot quietly swallow a tap to mean something else. + * + * It yields to a live card, though. The card sits on top of it in the column, so a chip that + * stayed up held the card 43px higher than it needed to be — and 43px up a phone tile is the + * difference between a card under the speaker's chin and a card across their face. There is also + * nothing for the chip to say in that moment: a claim is already on screen, which is the thing it + * exists to announce. + * + * Still drawn while the backlog is *open*, because there it is the way back out. + */ + const showChip = backlog.length > 0 && onTogglePinned !== undefined && (open || cards.length === 0); + if (shown.length === 0 && !showChip) return null; + + return ( +
{ + // A keyboard arriving, not a click landing. + // + // The player opens the backlog on this, and the backlog is drawn narrower than a live card + // — so when any focus counted, clicking the expand toggle on a clamped claim swapped the + // card the reader was halfway through for the list, at the list's width. They asked for + // more of the sentence in front of them and got it somewhere else, in a different column. + // Pressing a thumb on a live card did the same thing. + // + // `:focus-visible` is the browser's own answer to "did this focus come from a pointer", + // which is exactly the question, and the same signal the backlog chip already uses to + // decide when it is reachable. A click still focuses the control it landed on — it just no + // longer reads as having tabbed in. + if (event.target instanceof Element && !event.target.matches(':focus-visible')) return; + onFocusChange?.(true); + }} + // Only when focus leaves the stack entirely — moving between two cards, or out to the chip, + // must not collapse the list out from under the keyboard. + onBlur={event => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) onFocusChange?.(false); + }} + > +
event.stopPropagation()} + className={cx( + 'pointer-events-auto flex w-full flex-col gap-1.5', + // Nothing live and nothing open: the chip is on its own, and an empty box between it and + // the tile edge would still take its gap. + shown.length === 0 && 'hidden', + /** + * 156px of backlog, and the number is picked between two constraints rather than chosen. + * + * Cards are 80px and sit 6px apart. The newest one is crisp only if the box is at least + * `71.5 + 80 = 151.5px`, or the ramp reaches down into it. Two cards want 166px, so a box + * of 166 or more never scrolls — and a list that never scrolls sits at its own top, where + * the ramp correctly declines to fade anything. 156px is inside both: the newest card + * clears the ramp by 4px, and two cards overflow by 10. + * + * In px on this element rather than a percentage on the host, which is what it used to + * be. A percentage there was measured against the tile and then shared with the chip, so + * on a phone the chip's 43px came out of the list's 130 and left 87 — one card, most of + * it inside the ramp. + */ + open && 'no-scrollbar max-h-[9.75rem] min-h-0 overflow-y-auto' + )} + > + {shown.map(card => ( + + ))} +
+ + {/* `expanded` only where the chip is what is holding it open. A pointer closes the list by + leaving the tile, so a chip offering to Hide it would be describing something it cannot + do; a tap has nowhere to go, and there the way in has to double as the way out. */} + {showChip && onTogglePinned && ( + + )} +
+ ); +} + +/** + * The way into the backlog where there is no pointer to open it with. + * + * Shown on a device that cannot hover. Everywhere else the corner opens by pointing at the tile, + * and a permanent pill duplicating that would be furniture over the video — the Figma frame has no + * chip in it, and the resting player is quieter without one. It stays in the DOM there all the + * same, because a keyboard has no pointer either; how that is done, and why not with `sr-only`, is + * on the `className` below rather than restated here. + * + * Deliberately the same glyph the Claims button in the interaction bar uses, because it opens the + * same set of claims. Two different icons for one idea would be the harder thing to learn. + */ +function ClaimBacklogChip({ count, expanded, onClick }: { count: number; expanded: boolean; onClick: () => void }) { + return ( + + ); +} + +/** + * The card's top line: who said it, how the crowd has answered it, and the two ways to answer. + * + * `PositionRow` is not reused here, and the reason is size rather than taste: it is a container + * query that stacks its two pills vertically below ~230px, which is exactly the width this card + * wants to be. Reusing it would force the card wide enough to cover the face it sits beside. What + * matters is shared underneath — `useClaimResponseState` and `useClaimPositionControl` resolve the + * vocabulary and publish the response, so a factual claim still reads Verify/Dispute here and the + * share is the same number the claim page prints. + * + * The crowd split is shown up front, per the Figma card. It is worth knowing that this cuts against + * the usual argument for withholding it — a viewer who sees "65% agree" before answering is being + * nudged, and the tally becomes partly a measure of itself. Drawn as designed because it is a + * deliberate call about what the card is *for*: a running read of the room rather than a poll. + */ +function TickerClaimHeader({ + claimId, + spaceId, + speaker, + row, + entity, + onAnswered, +}: { + claimId: string; + spaceId: string; + speaker: DebateParticipant | null; + row: DebateClaim | null; + entity: Entity | null; + onAnswered: (claimId: string, position: boolean | null) => void; +}) { + const { responseKind, summary, control } = useDebateClaimResponse({ claimId, spaceId, row, entity }); + const openProfile = useOpenDebaterProfile(speaker); + + const position = control.viewerPosition; + + /** + * Reported on every change, including back to nothing. + * + * It used to latch on the first non-null side, which made the map a record of what the viewer + * *first* pressed rather than what they hold: switch from Agree to Disagree, or clear the side + * entirely, and the map kept the original. The end-of-debate scorecard reads this to decide which + * claims to skip and how to tally them, so a stale entry there is the viewer's own answer + * misreported back to them. + * + * Safe to fire on mount, when there is no position and nothing to say: `onAnswered` returns the + * same map when nothing changes, so React bails out rather than re-rendering every card. + */ + React.useEffect(() => { + onAnswered(claimId, position); + }, [position, claimId, onAnswered]); + + const copy = ENTITY_RESPONSE_COPY[responseKind]; + // Null on a claim nobody has answered, which is most of them — and a genuine 0% is a different + // statement from "no responses", so the share drops out rather than printing a zero. + const percent = summary.percent; + + return ( +
+ {/* `text-box` trimmed, as the frame has it, and on each text node rather than on the row. + The trim acts on a box's own line boxes, and these are flex items with their own; the + frame gives this line a 7px box in a 16px row, which is its cap height. Trimmed, the row + is the avatar's 16px and the words centre against it. Untrimmed, each 17px line box sets + the row instead and the card comes out a pixel taller than the frame draws. Per node and + not via a child selector, because the name now sits inside a button. */} + + {speaker && ( + // The same link as the name in the corner of the tile, and the same person — a reader + // looking at who said this should be able to go and look at them from here. + + )} + {percent !== null && ( + <> + {speaker && ( + + · + + )} + {/* Same wording as the verdict on the claim page — "65% agree", or "65% verify" on a + factual claim, so the share reads the same wherever it is printed. */} + + {percent}% {copy.positiveAction.toLowerCase()} + + + )} + + {/* 4px apart rather than the Figma card's 12px: those are bare 12px glyphs and these are + 20px buttons, so the same gap between glyph *centres* needs a smaller gap between boxes. + No error text here — it would make the card grow while the reader is part-way through it. */} + + control.respond(true)} + /> + control.respond(false)} + /> + +
+ ); +} + +/** + * One side of the answer, as an icon rather than a labelled pill. + * + * A labelled button turns the line into a form. An icon keeps the line a line — the reader takes + * in what was said, and the affordance is there in the corner of their eye if they feel strongly + * about it. The label survives as the accessible name and the tooltip, so nothing is lost to + * anyone reading it aloud or hovering. + * + * Thumbs for a stance claim, chevrons for a factual one, which is the split the rest of the app + * already draws: agreeing with a position and verifying a fact are different acts, and a thumb on + * "the SEC sued Coinbase" reads as approval rather than confirmation. + */ +function ClaimIconButton({ + responseKind, + position, + label, + selected, + disabled, + title, + onClick, +}: { + responseKind: 'stance' | 'veracity' | 'curation'; + position: boolean; + label: string; + selected: boolean; + disabled: boolean; + title: string; + onClick?: () => void; +}) { + return ( + + ); +} + +/** + * How wide a marker's hit area may grow, in px. + * + * WCAG 2.5.8 asks for 24×24 and the hash is 2×10, so the target needed to grow. Height is free — + * nothing else is stacked above or below a marker — but width is not, for two separate reasons, and + * both were measured rather than guessed. + * + * **Neighbours.** Markers sit where claims end, and claims cluster. Across the 37 corpus debates + * with two or more markers, 34% of adjacent pairs are closer than 24px on a phone-width track. A + * flat 24px target would cover the next claim a third of the time, so a tap would jump to the wrong + * one — trading an accessibility problem for a correctness one. Each target is therefore clamped to + * the distance to its nearest neighbour, so two can touch but never overlap. + * + * **The scrubber underneath.** These sit above the range input so a click reaches them at all, so + * whatever they cover is a place a drag cannot start. At 24px that is a mean 18.5% of the track and + * 60% on the densest debate; at 12px it is 10.4% and 44%. 12 keeps the bulk of the bar draggable + * and is still a six-fold target, and the height does the rest of the work. + */ +const MARKER_HIT_WIDTH_PX = 12; +/** Full 24px vertically, which costs nothing: the row is 20px and nothing else wants that band. */ +const MARKER_HIT_HEIGHT = 'h-6'; + +/** + * How wide marker `index`'s hit area may be, as a CSS length. + * + * Its own function because the rule is arithmetic rather than styling, and because a test cannot + * read it off the DOM: jsdom's CSS parser drops `clamp()` outright, so `style.width` comes back + * empty there whatever we set. + * + * `markers` is sorted by `atMs`, and `fraction` is `atMs / timelineMs`, so it is sorted by + * `fraction` too — the neighbours are the entries either side. A marker alone on the bar has no + * neighbour to crowd and takes the full width. + * + * The `clamp` floor keeps a target at least as wide as the hash it draws. Two claims can end on the + * same millisecond, and a zero-width button cannot be pressed or focused at all. + */ +export function markerHitWidth(markers: ClaimMarker[], index: number): string { + const before = index > 0 ? markers[index].fraction - markers[index - 1].fraction : Infinity; + const after = index < markers.length - 1 ? markers[index + 1].fraction - markers[index].fraction : Infinity; + const nearest = Math.min(before, after); + + if (!Number.isFinite(nearest)) return `${MARKER_HIT_WIDTH_PX}px`; + return `clamp(2px, ${(nearest * 100).toFixed(3)}%, ${MARKER_HIT_WIDTH_PX}px)`; +} + +/** Claim markers on the scrubber, each a place the viewer can jump to. */ +export function ClaimScrubberMarkers({ + markers, + onSeek, + className, +}: { + markers: ClaimMarker[]; + onSeek: (ms: number) => void; + className?: string; +}) { + if (markers.length === 0) return null; + + return ( +
+ {markers.map((marker, index) => { + return ( +
+ ); +} diff --git a/apps/web/core/debates/browse/debate-claims-panel.test.tsx b/apps/web/core/debates/browse/debate-claims-panel.test.tsx index 2ceaedeb66..daac612754 100644 --- a/apps/web/core/debates/browse/debate-claims-panel.test.tsx +++ b/apps/web/core/debates/browse/debate-claims-panel.test.tsx @@ -26,6 +26,12 @@ const mocks = vi.hoisted(() => ({ rowGroups: [] as Array>, /** Whether each rendered row's controls asked for the account-level match, in render order. */ positionControlOffersDebate: [] as boolean[], + /** What `useDebateTranscript` answers with, for the timecode on each row. */ + transcript: { data: undefined, isSuccess: true, isError: false } as { + data?: { segments: unknown[] }; + isSuccess: boolean; + isError: boolean; + }, })); vi.mock('~/core/debates/use-debate-transcript-claims', () => ({ @@ -129,6 +135,10 @@ vi.mock('~/core/debates/hooks', () => ({ mocks.rowGroups.push(groups); return { claims: [], isLoading: false, isError: false }; }, + // Reached through `useClaimTimings`, which the panel calls to put a timecode on each row. + // `mocks.transcript` lets a test supply segments; by default there are none, which is the shape + // of every debate recorded before claim timecodes existed. + useDebateTranscript: () => mocks.transcript, })); vi.mock('~/design-system/prefetch-link', () => ({ @@ -140,13 +150,22 @@ vi.mock('~/design-system/avatar', () => ({ Avatar: () =>
Winner? })); function claim(id: string, text: string, overrides: Partial = {}): TranscriptClaim { - return { id, text, spaceId: CLAIM_SPACE, ...overrides }; + return { + id, + text, + spaceId: CLAIM_SPACE, + blockId: 'block-1', + publishedTiming: null, + relationEntityId: null, + restated: false, + ...overrides, + }; } function grouped(byAuthor: Record, unattributed: TranscriptClaim[] = []) { const byAuthorSpaceId = new Map(Object.entries(byAuthor)); const all = [...byAuthorSpaceId.values()].flat().concat(unattributed); - return { all, byAuthorSpaceId, unattributed, totalCount: all.length }; + return { all, byAuthorSpaceId, unattributed, blocks: [], totalCount: all.length }; } function participant(spaceId: string, name: string, slot: 1 | 2): DebateParticipant { @@ -224,6 +243,27 @@ describe('DebateClaimsPanel', () => { expect(mocks.positionControlOffersDebate.every(offers => offers === false)).toBe(true); }); + it('shows when a claim was said once the debate carries timecodes', () => { + mocks.claims = grouped({ + [PRESTON_SPACE]: [claim('claim-1', 'Sleep matters.', { publishedTiming: { startMs: 134_600, endMs: 143_140 } })], + }); + + render(); + + expect(within(cardFor('Preston Mantel')).getByText('Said at 2:15')).toBeInTheDocument(); + }); + + // Every debate recorded before timecodes existed, which is nearly all of them. The row is the + // same row it always was; it just says nothing about when. + it('says nothing about timing for a debate that has none', () => { + mocks.claims = grouped({ [PRESTON_SPACE]: [claim('claim-1', 'Sleep matters.')] }); + + render(); + + expect(within(cardFor('Preston Mantel')).getByText('Sleep matters.')).toBeInTheDocument(); + expect(screen.queryByText(/^Said at /)).not.toBeInTheDocument(); + }); + it('links each claim to its entity in the space the claim lives in', () => { mocks.claims = grouped({ [PRESTON_SPACE]: [claim('claim-1', 'Sleep matters.')] }); @@ -250,7 +290,29 @@ describe('DebateClaimsPanel', () => { expect(mocks.responseControlProps.every(props => props.spaceId === CLAIM_SPACE)).toBe(true); }); - it('orders each debater\u2019s claims by the best ranking, unranked keeping transcript order', () => { + // A debate is an argument, and reading its claims out of sequence loses the thread. This used to + // sort by ranking score, which is the right answer for a feed of unrelated claims and the wrong + // one inside a transcript. + it('orders a debater\u2019s claims by when they were said, not by ranking', () => { + // The ranking would put the last thing said first, so it cannot be what decided this. + mocks.rankByClaimId = new Map([['cccc', 0]]); + mocks.claims = grouped({ + [PRESTON_SPACE]: [ + claim('cccc', 'C.', { publishedTiming: { startMs: 200_000, endMs: 204_000 } }), + claim('aaaa', 'A.', { publishedTiming: { startMs: 10_000, endMs: 14_000 } }), + claim('bbbb', 'B.', { publishedTiming: { startMs: 100_000, endMs: 104_000 } }), + ], + }); + + render(); + + expect(mocks.responseControlProps.map(props => props.entityId)).toEqual(['aaaa', 'bbbb', 'cccc']); + }); + + // Every claim of one turn carries that turn's window, so they tie \u2014 and on a debate with no + // timecodes at all, every claim ties. Relation `position` is random, so the ranking is a better + // answer to the tie than the order the graph happened to return them in. + it('falls back to the best ranking where nothing distinguishes when', () => { // Lowercase, dash-free ids: ranks match through `uuidToHex`, which strips dashes and lowercases. mocks.rankByClaimId = new Map([['cccc', 0]]); mocks.claims = grouped({ diff --git a/apps/web/core/debates/browse/debate-claims-panel.tsx b/apps/web/core/debates/browse/debate-claims-panel.tsx index 01fef7b19c..cef866a6ec 100644 --- a/apps/web/core/debates/browse/debate-claims-panel.tsx +++ b/apps/web/core/debates/browse/debate-claims-panel.tsx @@ -3,16 +3,21 @@ import * as React from 'react'; import { ClaimSummary } from '~/core/claims/browse/claim-summary'; -import { useClaimResponseState } from '~/core/claims/browse/use-claim-response-state'; import type { Debate, DebateClaim } from '~/core/debates/api'; -import { sortClaimsByBest, useClaimsBestOrder } from '~/core/debates/claims-best-order'; +import { + type ClaimTiming, + formatTimecode, + isAssertableMoment, + sortClaimsBySpokenOrder, +} from '~/core/debates/claim-timing'; +import { compareByBest, useClaimsBestOrder } from '~/core/debates/claims-best-order'; import { useDebateClaimsBySpaces } from '~/core/debates/hooks'; -import { PositionRow, useClaimPositionControl } from '~/core/debates/matchmaking/matchmaking-claim-card'; +import { PositionRow } from '~/core/debates/matchmaking/matchmaking-claim-card'; import { orderedParticipants, speakerLabel } from '~/core/debates/playback-utils'; import { type TranscriptClaim, claimsForParticipant, unmatchedClaims } from '~/core/debates/transcript-claims'; +import { useClaimTimings } from '~/core/debates/use-claim-timings'; import { useDebateTranscriptClaims } from '~/core/debates/use-debate-transcript-claims'; import { useDebateVotes } from '~/core/debates/use-debate-votes'; -import { usePrivySignIn } from '~/core/hooks/use-privy-sign-in'; import { useQueryEntities } from '~/core/sync/use-store'; import type { Entity } from '~/core/types'; import { NavUtils } from '~/core/utils/utils'; @@ -22,6 +27,7 @@ import { Close } from '~/design-system/icons/close'; import { PrefetchLink as Link } from '~/design-system/prefetch-link'; import { Text } from '~/design-system/text'; +import { useDebateClaimResponse } from './use-debate-claim-response'; import { WinnerVoteButton } from './winner-vote-button'; /** How many of a debater's claims show before "Show more". */ @@ -41,6 +47,10 @@ export function DebateClaimsPanel({ debate, onClose }: { debate: Debate; onClose const votes = useDebateVotes(debate); // Same query key as the feed's count badge, so opening the panel doesn't refetch. const { claims, isLoading, error } = useDebateTranscriptClaims(debate.id, debate.claim.space_id); + // When each claim was said. Published timecodes where they exist, matched against the transcript + // otherwise. This drives the order as well as the timecode, so the panel waits on it — see + // `isOrdering`. + const { timings, isReady: timingsReady } = useClaimTimings(debate.id, claims); // Every claim a debate publishes lands in the debate's own space, so one lookup covers the panel. const claimsSpaceId = React.useMemo( @@ -94,23 +104,36 @@ export function DebateClaimsPanel({ debate, onClose }: { debate: Debate; onClose return map; }, [rowsQuery.claims]); - // Held back the way the debate feed holds its rows back while the same ranking loads: painting - // transcript order first and reordering a moment later moves claims under someone already - // reading, and can carry one across the "Show more" fold after they have looked at it. - const isOrdering = isLoading || !rankingReady; + // Held back until both inputs have landed: painting one order and reordering a moment later + // moves claims under someone already reading, and can carry one across the "Show more" fold + // after they have looked at it. The timings matter more than the ranking now — without them + // every claim ties, so the whole list would paint in ranking order and then rearrange itself. + const isOrdering = isLoading || !rankingReady || !timingsReady; + + /** + * In the order the debate said them, ranking breaking the ties. + * + * These rows were ordered by ranking score, which is the right answer for a feed of unrelated + * claims and the wrong one inside a transcript: a debate is an argument, and reading its claims + * out of sequence loses the thread. Ties are every claim of a single turn — a turn is one moment + * as far as the resolver is concerned — and the ranking still decides those. + */ + const inSpokenOrder = React.useCallback( + (subset: TranscriptClaim[]) => sortClaimsBySpokenOrder(subset, timings, compareByBest(rankByClaimId)), + [timings, rankByClaimId] + ); const orphaned = React.useMemo( () => isOrdering ? [] - : sortClaimsByBest( + : inSpokenOrder( unmatchedClaims( claims, participants.map(participant => participant.profile_space_id) - ), - rankByClaimId + ) ), - [claims, participants, rankByClaimId, isOrdering] + [claims, participants, inSpokenOrder, isOrdering] ); React.useEffect(() => { @@ -153,12 +176,11 @@ export function DebateClaimsPanel({ debate, onClose }: { debate: Debate; onClose
@@ -175,6 +197,7 @@ export function DebateClaimsPanel({ debate, onClose }: { debate: Debate; onClose claims={orphaned} rowsByClaimId={rowsByClaimId} entitiesByClaimId={entitiesByClaimId} + timings={timings} isLoading={false} error={null} /> @@ -189,12 +212,14 @@ function ClaimList({ claims, rowsByClaimId, entitiesByClaimId, + timings, isLoading, error, }: { claims: TranscriptClaim[]; rowsByClaimId: Map; entitiesByClaimId: Map; + timings: Map; isLoading: boolean; error: Error | null; }) { @@ -228,6 +253,7 @@ function ClaimList({ claim={claim} row={rowsByClaimId.get(claim.id) ?? null} entity={entitiesByClaimId.get(claim.id) ?? null} + timing={timings.get(claim.id) ?? null} /> ))} @@ -259,7 +285,17 @@ function ClaimList({ * controls are space-scoped, so there is nothing correct to point either one at — better a dead row * than one that navigates somewhere wrong or publishes a response into the wrong space. */ -function ClaimRow({ claim, row, entity }: { claim: TranscriptClaim; row: DebateClaim | null; entity: Entity | null }) { +function ClaimRow({ + claim, + row, + entity, + timing, +}: { + claim: TranscriptClaim; + row: DebateClaim | null; + entity: Entity | null; + timing: ClaimTiming | null; +}) { if (claim.spaceId === null) { return ( @@ -275,11 +311,33 @@ function ClaimRow({ claim, row, entity }: { claim: TranscriptClaim; row: DebateC {claim.text} + ); } +/** + * When this claim was said. + * + * Nothing is drawn for a claim whose moment is only known to the turn (`source: 'block'`, a ~30s + * window) or not at all. A timecode reads as "here it is", and pointing at a half-minute of video + * is a worse answer than staying quiet — the reader still has the claim text and the panel. + */ +function ClaimTimecode({ timing }: { timing: ClaimTiming | null }) { + // The same bar the card over the video clears. This used to admit any matched claim, so a loose + // match the live layer would not draw still printed a time to the second — and nothing about + // "Said at 2:29" tells the reader it was inferred. A claim below the bar is still *ordered* by + // its match; it just does not get to name a second. + if (!isAssertableMoment(timing)) return null; + + return ( + + Said at {formatTimecode(timing.startMs)} + + ); +} + /** * The panel's compact rendition of the card's controls: the side pills and the summary, without the * card chrome around them. @@ -303,38 +361,13 @@ function PanelClaimControls({ entity: Entity | null; }) { // The claim's row title is drawn by `ClaimRow` above, so nothing is passed for it here. - const { - responseKind, - isResponseKindResolved, - isViewerResponseResolved, - responseBlockedReason, - summary, - claim, - positions, - readiness, - } = useClaimResponseState({ + const { responseKind, summary, control } = useDebateClaimResponse({ claimId, spaceId, row, entity, }); - const promptSignIn = usePrivySignIn(); - const control = useClaimPositionControl({ - claim, - positions, - readiness, - answersReady: isResponseKindResolved && isViewerResponseResolved, - responseBlockedReason, - onRequireSignIn: promptSignIn, - // No offer here, so no faces borrowed from one. The panel deliberately has no end slot — the - // reader is already watching the debate this claim is being argued in — and the merge exists to - // stop a card offering a debate on a side showing nobody to debate. With nothing offered, all it - // could do is put an unrelated stranger from an account-level match inside a pill on a row about - // this debate's own participants. - offersDebate: false, - }); - return (
({ controller: null as unknown })); +const mocks = vi.hoisted(() => ({ + controller: null as unknown, + ticker: null as unknown, + /** The `open` prop each render handed the stack, so a test can read the latest. */ + stackOpens: [] as boolean[], +})); + +/** The ticker's shape with nothing in it, which is what most of these tests want. */ +const emptyTicker = () => ({ + cardsBySlot: new Map(), + historyBySlot: new Map(), + markers: [], + answers: new Map(), + onAnswered: vi.fn(), + rowsByClaimId: new Map(), + entitiesByClaimId: new Map(), + participantByClaimId: new Map(), +}); vi.mock('~/core/debates/use-debate-playback', () => ({ useDebatePlayback: () => mocks.controller, @@ -25,6 +42,24 @@ vi.mock('~/core/hooks/use-space', () => ({ useSpace: () => ({ space: null }), })); +// The player carries a claim ticker now, which reaches for the transcript, the sync engine and a +// query client. These tests are about the audio gate; the ticker has its own suite. +vi.mock('./debate-claim-ticker', () => ({ + useDebateClaimTicker: () => mocks.ticker, + // Stands in for the stack so the *player's* half is what is under test: whether it opens the + // corner, and whether it lets go when the stack is gone. Clicking it reports focus arriving, + // which is all the player ever learns from the real one. + DebateClaimTickerStack: ({ open, onFocusChange }: { open?: boolean; onFocusChange?: (f: boolean) => void }) => { + mocks.stackOpens.push(open === true); + return ( + + ); + }, + ClaimScrubberMarkers: () => null, +})); + const participant = (slot: 1 | 2): DebateParticipant => ({ participant_slot: slot, @@ -32,6 +67,9 @@ const participant = (slot: 1 | 2): DebateParticipant => position_label: slot === 1 ? 'For' : 'Against', }) as unknown as DebateParticipant; +/** Only what the player reads: its id, and the space the ticker looks for claims in. */ +const debate = { id: 'debate-1', claim: { space_id: 'space-1' } } as unknown as Debate; + const votes: DebateVotesResult = { sharePercentFor: () => null, isMyPick: () => false, @@ -51,6 +89,7 @@ function controllerFixture(overrides: { /** The browser refused to autoplay — see `useDebatePlayback`. */ autoplayBlocked?: boolean; playing?: boolean; + playbackEnded?: boolean; }) { return { slot1VideoRef: { current: null }, @@ -65,7 +104,7 @@ function controllerFixture(overrides: { userPaused: false, isScrubbing: false, isResuming: overrides.isResuming ?? false, - playbackEnded: false, + playbackEnded: overrides.playbackEnded ?? false, mutedByUser: overrides.mutedByUser, setMutedByUser: vi.fn(), playheadSeconds: 5, @@ -95,10 +134,9 @@ function renderPlayer( reactStrictMode = false ) { mocks.controller = controllerFixture(overrides); - const { container, rerender, unmount } = render( - , - { reactStrictMode } - ); + const { container, rerender, unmount } = render(, { + reactStrictMode, + }); const [slot1, slot2] = Array.from(container.querySelectorAll('video')); return { slot1, @@ -107,13 +145,15 @@ function renderPlayer( /** Re-render with a new controller state, as the hook's own state changes would. */ update(next: { mutedByUser: boolean; turnSlot: 1 | 2; isResuming?: boolean }) { mocks.controller = controllerFixture(next); - rerender(); + rerender(); }, }; } beforeEach(() => { mocks.controller = null; + mocks.ticker = emptyTicker(); + mocks.stackOpens = []; vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {}); vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => {}); }); @@ -244,3 +284,69 @@ describe('a refused autoplay', () => { expect(container.querySelector('[aria-label="Resume debate"]')).toBeNull(); }); }); + +/** + * The backlog opens on keyboard focus and stays open until focus leaves — a latch, because the + * corner has to survive a keyboard moving between the cards inside it. + * + * Nothing releases that latch if the stack is taken away while the keyboard is in it. A removed + * element fires no `blur`, so the stack's own handler — the only thing that ever reports focus + * gone — never runs; see the ticker's suite, which asserts exactly that absence. The stack goes + * when the debate ends, when the tile scrolls out of the preload window, and when a debate has + * nothing to show yet, so this is ordinary rather than exotic. + * + * `pinnedSlot` is the same shape on a touch screen, where the only other release is a + * `pointerleave` that early-returns on anything but a mouse. + */ +describe('a backlog latch outliving its stack', () => { + const withCardsForSlot1 = () => ({ + ...emptyTicker(), + // The stack is mocked, so only the length is read. + cardsBySlot: new Map([[1, [{}]]]), + }); + + const renderAt = (playbackEnded: boolean) => { + mocks.controller = controllerFixture({ mutedByUser: true, turnSlot: 1, playbackEnded }); + return ; + }; + + const lastOpen = () => mocks.stackOpens[mocks.stackOpens.length - 1]; + + /** Scoped to this render's own container: the suite has no cleanup between tests. */ + const stackIn = (container: HTMLElement) => container.querySelector('[data-testid="claim-stack"]'); + + it('closes the corner again once the stack it was holding has gone', () => { + mocks.ticker = withCardsForSlot1(); + const { container, rerender } = render(renderAt(false)); + + // A keyboard tabs in and the corner opens into the backlog. + fireEvent.click(stackIn(container) as HTMLElement); + expect(lastOpen()).toBe(true); + + // The debate ends. The stack unmounts without ever reporting that focus left it. + rerender(renderAt(true)); + expect(stackIn(container)).toBeNull(); + + // Replay. The corner has no keyboard and no pointer in it, so it starts closed. + rerender(renderAt(false)); + expect(lastOpen()).toBe(false); + }); + + // The same latch, released by the same cleanup: a tile scrolled out of the preload window empties + // the ticker, which takes the stack with it. + it('closes the corner when the ticker empties rather than the debate ending', () => { + mocks.ticker = withCardsForSlot1(); + const { container, rerender } = render(renderAt(false)); + + fireEvent.click(stackIn(container) as HTMLElement); + expect(lastOpen()).toBe(true); + + mocks.ticker = emptyTicker(); + rerender(renderAt(false)); + expect(stackIn(container)).toBeNull(); + + mocks.ticker = withCardsForSlot1(); + rerender(renderAt(false)); + expect(lastOpen()).toBe(false); + }); +}); diff --git a/apps/web/core/debates/browse/debate-feed-player.tsx b/apps/web/core/debates/browse/debate-feed-player.tsx index f177781c45..e0c45b2e41 100644 --- a/apps/web/core/debates/browse/debate-feed-player.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.tsx @@ -5,21 +5,20 @@ import * as React from 'react'; import cx from 'classnames'; import type { Debate, DebateParticipant } from '~/core/debates/api'; -import { DebateTileChip, tileChipSurface } from '~/core/debates/debate-video-tile'; +import type { ClaimMarker } from '~/core/debates/claim-ticker'; import { type TurnState, clampSeconds, speakerLabel } from '~/core/debates/playback-utils'; import { useDebatePlayback } from '~/core/debates/use-debate-playback'; import type { DebateVotesResult } from '~/core/debates/use-debate-votes'; import { usePlaybackAnalytics } from '~/core/debates/use-playback-analytics'; -import { useEntitySidePanel } from '~/core/hooks/use-entity-side-panel'; -import { useSpace } from '~/core/hooks/use-space'; import { releaseVideo } from '~/core/utils/video/release-video'; import { Avatar } from '~/design-system/avatar'; import { RetrySmall } from '~/design-system/icons/retry-small'; import { Text } from '~/design-system/text'; +import { ClaimScrubberMarkers, DebateClaimTickerStack, useDebateClaimTicker } from './debate-claim-ticker'; import { Pause, Play, Speaker, SpeakerMuted } from './icons'; -import { WinnerVoteButton } from './winner-vote-button'; +import { useOpenDebaterProfile } from './use-open-debater-profile'; type DebateFeedPlayerProps = { debate: Debate; @@ -60,7 +59,6 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb playheadSeconds, timelineSeconds, turnState, - activeSlot, subtitle, onPlaybackTick, togglePlayback: togglePlaybackRaw, @@ -107,11 +105,179 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb } }, [active, awaitingTap, isScrubbing, playbackEnded, playing, ready, resumeBoth, suspend]); + // The live claim layer. Loaded alongside the recordings so a card is ready the moment the claim + // it belongs to is spoken, rather than appearing a beat late on the first one. + const ticker = useDebateClaimTicker(debate, { + playheadMs: playheadSeconds * 1000, + timelineMs: timelineSeconds * 1000, + enabled: active || preload, + }); + const showControls = ready && (awaitingTap || (playbackEnded && !hasVoted)); // End of an unvoted debate offers a replay; a stopped one shows the paused glyph. const showReplay = ready && playbackEnded && !hasVoted; const showPausedGlyph = ready && awaitingTap && !playbackEnded; + // Whether the scrubber is on screen, which the claim stack has to know as well as the scrubber + // itself — it sits in the same bottom band and lifts clear of it. Hover is the remaining case and + // stays in CSS (`group-hover` on both), since neither element can ask about the other in a class. + // + // Keyboard focus is here rather than a `focus-within:` variant for exactly that reason: tabbing + // to the timeline has to raise the cards too, and a sibling cannot see focus land inside the + // scrubber. Guarded on `relatedTarget` so moving between the markers and the range input — both + // inside the wrapper — does not blink it off and on. + const [timelineFocused, setTimelineFocused] = React.useState(false); + const scrubberShown = showControls || timelineFocused; + + // Which debater's corner is showing their backlog rather than their live cards. + // + // Pointer state is tracked per tile rather than on the stack itself, because the backlog has to + // open from anywhere over that debater's half. A claim card is on screen for a few seconds at a + // time, so a hover target made of the cards is a target that is usually not there — and "hover to + // see what they said" has to work in the silences, which is most of a debate. + // + // Per tile rather than per player so the two corners stay independent: pointing at one debater + // opens their claims and leaves the other's alone, which is the whole reason for splitting them. + const [pointerOverSlot, setPointerOverSlot] = React.useState(null); + const [focusedSlot, setFocusedSlot] = React.useState(null); + // Held open by the chip rather than by the pointer — the only way in on a touch screen, where + // there is no hover to end and so no hover to hold it. + const [pinnedSlot, setPinnedSlot] = React.useState(null); + /** + * The slot whose backlog the pointer has opened, held until the pointer leaves that tile. + * + * A latch rather than a live test of where the pointer is. Reaching the backlog means crossing + * into it, and a rule that closed the moment the pointer came back down would put the cards + * permanently out of reach — no scrolling it, no expanding a claim, no answering one. So the + * pointer opens it by going above the live card, and then the tile holds it open. + */ + const [backlogHoverSlot, setBacklogHoverSlot] = React.useState(null); + + /** The same condition the stack is given, so the corner's box can cap itself only when open. */ + const claimsOpenFor = (slot: number) => { + // A backlog opened on purpose stays open, and a claim arriving lands at the bottom of it — + // that is the whole point of having opened it. Same for a keyboard that has tabbed into it. + if (pinnedSlot === slot || focusedSlot === slot) return true; + return pointerOverSlot === slot && backlogHoverSlot === slot; + }; + + /** + * The top edge of the live card, in viewport coordinates, per slot. + * + * Remembered rather than measured live, because opening the backlog makes the corner taller and + * moves its top edge up past the pointer. Test against that and the pointer would be above the + * corner, then inside it, then above it again — the list would flicker open and shut under a + * stationary mouse. The line stays where the *card* drew it. + */ + const liveCornerTop = React.useRef(new Map()); + + /** + * The pointer moving over, or leaving, one debater's tile. + * + * Filtered to a real mouse. Touch browsers synthesise `pointerenter` from a tap, so without this + * every tap on the video — including the tap that pauses it — would throw the claim corner open, + * and nothing would close it again since there is no corresponding leave. On touch the chip is + * the way in, deliberately and only. + * + * Leaving also clears the pin, so a mouse user who clicked the chip and then moved away does not + * leave the corner stuck open behind them. + */ + const onTileHover = (slot: number) => (event: React.PointerEvent, hovered: boolean) => { + if (event.pointerType !== 'mouse') return; + const clearSlot = (current: number | null) => (current === slot ? null : current); + + if (!hovered) { + setPointerOverSlot(clearSlot); + setBacklogHoverSlot(clearSlot); + setPinnedSlot(clearSlot); + liveCornerTop.current.delete(slot); + return; + } + + setPointerOverSlot(slot); + + const live = (ticker.cardsBySlot.get(slot)?.length ?? 0) > 0; + // Measured only while the card itself is what the corner is drawing. Once the backlog is open + // the remembered edge is the one that counts, and reading the box again would move the line. + // Skipped entirely with nothing live, which is most of a debate and the whole cost here. + if (live && !claimsOpenFor(slot)) { + const corner = event.currentTarget.querySelector('[data-claim-corner]'); + if (corner) liveCornerTop.current.set(slot, corner.getBoundingClientRect().top); + } + + setBacklogHoverSlot(current => { + if (current === slot) return current; + // Nothing being presented: the backlog answers to the tile as a whole. + if (!live) return slot; + // A claim is up, so only the video above it asks for the backlog. The card's own band belongs + // to the card, and a pointer resting there cannot swap the claim out mid-sentence. + const edge = liveCornerTop.current.get(slot); + return edge !== undefined && event.clientY < edge ? slot : current; + }); + }; + + /** + * Whether slot's corner has a stack at all. `claimsFor` draws nothing when this is false. + * + * Lifted out of it so the effect below can watch the same condition: both latches that open the + * backlog are held up here, and a stack that has gone cannot release either one. + */ + const stackShownFor = (slot: number) => { + if (playbackEnded) return false; + return (ticker.cardsBySlot.get(slot)?.length ?? 0) > 0 || (ticker.historyBySlot.get(slot)?.length ?? 0) > 0; + }; + + const slot1StackShown = stackShownFor(1); + const slot2StackShown = stackShownFor(2); + + /** + * Let go of a corner whose stack has been taken away. + * + * `focusedSlot` is cleared only by the stack's own `onBlur`, and a focused element removed from + * the document fires no `blur` — the ticker's suite asserts that absence directly. So a viewer + * who tabs into the backlog and then reaches the end of the debate, or scrolls the tile out of + * the preload window, left the slot latched: on replay the corner opened straight into backlog + * mode with nothing in it, and nothing short of tabbing back in and out closed it again. + * + * `pinnedSlot` has the same hole on a touch screen, where the only other release is a + * `pointerleave` that early-returns on anything but a mouse — so both are cleared here. + */ + React.useEffect(() => { + const releaseIfGone = (shown: boolean, slot: number) => { + if (shown) return; + const clear = (current: number | null) => (current === slot ? null : current); + setFocusedSlot(clear); + setPinnedSlot(clear); + }; + + releaseIfGone(slot1StackShown, 1); + releaseIfGone(slot2StackShown, 2); + }, [slot1StackShown, slot2StackShown]); + + const claimsFor = (slot: number) => { + const cards = ticker.cardsBySlot.get(slot) ?? []; + const history = ticker.historyBySlot.get(slot) ?? []; + if (!stackShownFor(slot)) return null; + + const pinned = pinnedSlot === slot; + const clearSlot = (current: number | null) => (current === slot ? null : current); + + return ( + setPinnedSlot(current => (current === slot ? null : slot))} + onFocusChange={focused => setFocusedSlot(current => (focused ? slot : clearSlot(current)))} + participantByClaimId={ticker.participantByClaimId} + rowsByClaimId={ticker.rowsByClaimId} + entitiesByClaimId={ticker.entitiesByClaimId} + onAnswered={ticker.onAnswered} + /> + ); + }; + // Clicking the video briefly flashes the action it just took — feedback only, not a control. const [flash, setFlash] = React.useState<{ icon: 'play' | 'pause'; visible: boolean }>({ icon: 'play', @@ -148,7 +314,9 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb data-debate-active={active ? 'true' : 'false'} data-debate-playing={playing ? 'true' : 'false'} data-debate-autoplay-blocked={autoplayBlocked ? 'true' : 'false'} - className="group relative flex flex-col gap-2" + // No gap and one radius on the outside: the two tiles are a single surface in the Figma + // frame, which is what lets the subtitle straddle the seam rather than sit inside one tile. + className="group relative flex flex-col overflow-hidden rounded-xl" > @@ -183,6 +352,11 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb // playback — otherwise there's no way to hear audio. Once unmuted it recedes // to hover-only on desktop; touch has no hover, so on mobile it stays visible // or there'd be no way to find it again. + // + // `no-hover:` as well as `md:`, because the two ask different questions. A tablet + // held in landscape is wider than the `md` breakpoint and still has no hover, so + // width alone left the control faded out but tappable there — a tap aimed at + // play/pause muted the debate instead, with nothing able to bring the control back. { @@ -192,7 +366,7 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb className={ mutedByUser ? undefined - : 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 md:opacity-100' + : 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 md:opacity-100 no-hover:opacity-100' } > {mutedByUser ? : } @@ -208,27 +382,50 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb videoRef={slot2VideoRef} audible={playing && turnState?.slot === 2} countdown={playing && turnState?.slot === 2 ? turnState : null} - subtitle={activeSlot === 2 ? subtitle : null} mutedByUser={mutedByUser} isResuming={isResuming} onPlaybackTick={onPlaybackTick} onToggle={toggleFromVideo} - votes={votes} + claims={claimsFor(2)} + claimsOpen={claimsOpenFor(2)} + onClaimsHoverChange={onTileHover(2)} + // This is the half the scrubber sits in, so its claim corner is the one that has to lift + // clear of the bar. + clearScrubber={scrubberShown ? 'always' : 'on-hover'} + // Taller than the top tile's, per the frame. + scrimClassName="h-[4.625rem]" scrubber={ ready ? ( // Always available so the viewer can seek. During playback it recedes to // hover-only and drops pointer-events so it can't swallow play/pause taps. + // `pointer-events-none` does not stop keyboard focus reaching the range input, which + // is what brings it back up for a viewer who never touches the pointer.
setTimelineFocused(true)} + onBlur={event => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setTimelineFocused(false); + }} className={cx( 'transition-opacity', - showControls + scrubberShown ? 'opacity-100' - : 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100' + : // `[&_button]:` as well as the wrapper itself, because the claim markers inside + // set `pointer-events-auto` on themselves — they have to, so a drag can pass + // between them to the range input underneath. An explicit value beats an + // inherited one, so `pointer-events-none` here never reached them and a fully + // transparent marker stayed clickable: a click meant to pause the video seeked + // it instead. The descendant selector outranks the marker's own class. + // + // Focusability is deliberately untouched. Tabbing to a marker sets + // `timelineFocused`, which is what brings the scrubber back into view, so the + // keyboard route in depends on them staying in the tab order while hidden. + 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 [&_button]:pointer-events-none group-hover:[&_button]:pointer-events-auto' )} > + {/* Straddling the seam between the tiles, which is the one strip of the player that is never + a face — and the one place it cannot land on top of the claim stack. + + `text-box` trims the line box to the cap-height band, which is what makes the *glyphs* + centre on the seam rather than the box that contains them. Calibre's metrics are + asymmetric, so a plainly-centred pill puts the type about 2px low — visible on a rule the + eye is already using the seam as. Figma's own frame specifies the same trim. Browsers + without it fall back to the box being centred, which is where this started. + + `w-max` is what makes the cap above it mean anything. An absolutely positioned box with + `left: 50%` and an automatic width is shrink-to-fit against the space *from that point to + the container's edge* — half the tile — so the caption wrapped at 178px however high the + max-width was set, and raising the cap to 90% changed nothing at all. Sizing to + max-content and letting the cap do the clamping is the fix: measured on a 355px phone + tile, the same caption goes from 178px over three lines to 239px over two. + + 90% of the width on a phone, 70% above it. A subtitle is one whole transcript segment and + those are short: across 7,359 of them the median is 27 characters, the 99th is 33, and the + longest in the corpus is 41. At the explore card's 484px tile 70% leaves 327px of line and + the longest segment measures 284, so on a desktop they already never wrap and widening + would only loosen the pill around the same one line. A ~355px phone tile leaves 236px, + which is where a segment starts folding onto a second line and taking the caption off the + seam. */} + {subtitle && ( + + {subtitle} + + )} + {/* Mobile only — desktop has the persistent play/pause beside the mute control. */} {showPausedGlyph && ( - {/* Bottom gradient scrim for legibility of the overlaid controls. */} -
+ {/* Bottom gradient scrim for legibility of the overlaid controls. All the way to black, per + the frame — the name that sits on it is regular weight and carries no text shadow. */} +
- {topLeft &&
{topLeft}
} + {topLeft &&
{topLeft}
} {countdown && } - {subtitle && ( -
- - {subtitle} - + {/* This debater's claims, in the bottom-right of their own tile. One corner each rather than + one for the player: a viewer is looking at whoever is talking, and a shared corner asks + the eye to leave the speaker in order to read what the speaker is saying. + + The height cap is on this box rather than on the list inside it. A percentage max-height + resolves against the parent's height, and the list's parent is content-sized — so capping + the list made it 63% of a box it had itself defined, and the whole stack ended up pinned to + the top of the tile instead of the bottom. Here the percentage is of the tile, which the + aspect ratio makes definite, and `bottom-3` keeps it anchored where it belongs. + + 60% is roughly the 168px of a 291px tile the frame gives the stack. Left to fill the tile + the open list climbed to the debater's chin — more of their face than it needs, and + further than the edge fade can dissolve. + + 360px and two lines — a departure from the frame's 209px and three lines, and a + deliberate one. + + Measured over all 854 published claims in Calibre at 16/17: at the frame's 209px the + median claim runs to *four* lines and only 30% are shown whole. Width and lines both buy + legibility, but width is what keeps the card short, and a short card is what keeps it + below the speaker's chin — the face is in the middle of the tile, so height costs more + than width does. Two lines at 360px show 62% of claims whole against three lines at 260px + showing 66%: the same reading in a card 17px shorter. + + 75% is where the explore card's 484px tile lands on the 360 cap; the cap is what holds a + far wider fullscreen player to the same card rather than a 450px one. Half the tile was + tried and reverted — 242px reads better as a corner label but shows only about 13% of + claims whole, which is a look bought with most of the legibility. + + Back on the name's own line, and drawn over it — `z-[11]` against the name's `z-10`, + under the subtitle's `z-20`. The corner had been lifted clear of the name, which cost 26px + of the tile's height for a gap nobody asked for. Sharing the line is what lets the card + have the full width: the name is still there and still a link the moment no card covers + it, and behind a card it goes under 30% dark and a 44px blur rather than being switched + off, which is the one thing that reads worse than either. + + A phone splits the difference by state rather than picking one width. A live card takes + the whole tile — 93% of ~361px is 337, a 313px line, where two lines hold about 45% of + claims whole — because one claim over a video is there to be read at a glance and there + is nothing else in the band to share with. The backlog drops back to 62%, because a list + you have opened to scroll through should leave the debate visible behind it, and that is + also where the dissolve earns its place again. + + `items-end` because that cap is a cap, not a width. A flex column stretches its children + by default, which drew the little claims chip as a 209px bar with two words adrift in it. + The cards ask for the full width themselves; everything else here should be its own size, + against the edge the corner is anchored to. + + No height cap here any more. It used to carry one, which was wrong twice over: measured + against the tile, so the same class meant a different number of cards on a phone than on + a desktop; and shared with the chip, so on a phone the chip's 43px came out of the list's + budget and left one card, most of it under the dissolve. The list caps itself in px now — + see `max-h-[9.75rem]` on the scroll box, which is sized against the cards rather than + against the picture. + + `justify-end` is still load-bearing: it overflows *downward* once its content is taller + than its box, which is how a second card used to push the newest one 78px below the + corner and into the tile's own `overflow-hidden`. Nothing here may set a height the + content can exceed. */} + {claims && ( +
+ {claims}
)} - {/* - * The tile's bottom line: who is speaking on the left, the winner vote on - * the right. - * - * One row rather than two absolutely-positioned corners. They used to be - * `left-4` and `right-4` independently, which is fine at the width a feed - * card gives a tile and not at the width a 312px gallery card does — the - * name and its Agree/Disagree chip simply ran on underneath the button, - * which reads as "Ag…" and "Disagr…" with a pill on top. A flex row cannot - * overlap: the identity takes what is left after the vote, and the name - * truncates into it. - */} -
- {/* Debater identity: avatar + name + position, opens their personal space in the side panel. */} - - - {participant && ( - votes.castVote(participant)} - /> + {/* Debater identity, opens their personal space in the side panel. On the left, opposite the + claim corner. + + A generous 55%, and it no longer rations the claim corner's width: the corner shares this + row and draws over it rather than sitting beside it. It also stays put — it used to fade + out under a card, which cost the viewer the link to the debater's profile exactly when + they were reading something that debater had said. + + Absolute rather than the flex row #2466 put here. That row exists so the name cannot run + under the "Winner?" pill at a 312px gallery width; the pill and the position chip are off + the tile in this redesign — winner voting lives in the scorecard and the claims panel — so + the name has the band to itself and there is nothing left to overlap. The 55% is still + what keeps it clear of the claim card above. */} + {scrubber &&
{scrubber}
}
@@ -446,11 +750,21 @@ function CountdownBadge({ seconds, progress }: { seconds: number; progress: numb const remaining = 1 - Math.max(0, Math.min(1, progress)); const degrees = remaining * 360; return ( -
- {Math.ceil(seconds)} +
+ + + {Math.ceil(seconds)} +
); } @@ -493,12 +807,14 @@ const isScrubKey = (key: string) => function FeedScrubber({ currentTime, duration, + markers, onSeek, onScrubStart, onScrubEnd, }: { currentTime: number; duration: number; + markers: ClaimMarker[]; onSeek: (seconds: number) => void; onScrubStart: () => void; onScrubEnd: () => void; @@ -510,6 +826,17 @@ function FeedScrubber({
+ {/* Above the range input, which is the only way a marker can be clicked at all: the input is + transparent but covers the whole bar, so at a lower z it swallowed every marker click and + scrubbed to the pixel instead of seeking to the claim. The comment here used to claim the + opposite, which is how it survived. + + The markers' wrapper is `pointer-events-none`, so only the targets themselves intercept + and a drag starting anywhere else along the bar still reaches the input. Those targets are + no longer the 2px hashes, though: they are up to 12px wide, clamped to the gap to the next + claim — a mean 10% of the track, 44% on the densest debate in the corpus. See + {@link MARKER_HIT_WIDTH_PX} for why that ceiling and not 24. */} + onSeek(ms / 1000)} className="z-3" /> void; }) { - const claimRef = React.useRef(null); + const [claimElement, setClaimElement] = React.useState(null); const [isClaimExpanded, setIsClaimExpanded] = React.useState(false); - const [isClaimOverflowing, setIsClaimOverflowing] = React.useState(false); React.useEffect(() => setIsClaimExpanded(false), [claim]); - React.useLayoutEffect(() => { - const element = claimRef.current; - if (!element || isClaimExpanded) return; - - const measureOverflow = () => - setIsClaimOverflowing( - exceedsLineClamp({ - contentHeight: element.scrollHeight, - clampedHeight: element.clientHeight, - // Read on every measure rather than once: the breakpoint swaps the whole type scale, so a - // rotation or a resize past 767px changes the line height this is counting in. - lineHeight: parseFloat(getComputedStyle(element).lineHeight), - maxLines: CLAIM_CLAMP_LINES, - }) - ); - measureOverflow(); - - if (typeof ResizeObserver === 'undefined') return; - - const observer = new ResizeObserver(measureOverflow); - observer.observe(element); - return () => observer.disconnect(); - }, [claim, isClaimExpanded]); + const isClaimOverflowing = useLineClampOverflow(claimElement, { + maxLines: CLAIM_CLAMP_LINES, + enabled: !isClaimExpanded, + contentKey: claim, + }); return (
@@ -625,7 +606,7 @@ function DebateTitleHeader({

+ ); } -export function Play({ color }: Props) { +export function Play({ color, size = 18 }: Props & { size?: number }) { return ( - + + + = (maxLines + 1) * lineHeight; } + +/** + * {@link exceedsLineClamp}, measured off a live element and kept current as it resizes. + * + * Two surfaces clamp claim text and offer a control to unclamp it — the feed's debate title and + * the claim cards over the video — and the measurement is the fiddly half: the line height has to + * be re-read on every measure because the breakpoint swaps the type scale, and a `ResizeObserver` + * is the only thing that notices the text rewrapping at a width the component never hears about. + * One copy, because a rule decided twice is a rule that will eventually disagree with itself. + * + * `enabled: false` (the text is currently expanded) deliberately *keeps* the last answer rather + * than reporting `false`. An expanded element does not overflow — that is the point of expanding + * it — and resetting here would take away the control that collapses it again. + */ +export function useLineClampOverflow( + /** + * The clamped element, held in state by the caller (`ref={setElement}`) rather than in a ref. + * + * It has to be the node itself and not a `RefObject`, because the node can be replaced while + * every other input stays identical: a caller that wraps the text in a button once it *is* + * truncated re-parents it, React remounts it, and an effect keyed on the ref object would not + * re-run — leaving the observer below attached to a detached node, which reports zero height and + * latches the answer back to "fits" for good. Passing the element puts it in the dependency list. + */ + element: HTMLElement | null, + { + maxLines, + enabled = true, + /** Changes when the text does, so the measurement is redone for new content. */ + contentKey, + }: { maxLines: number; enabled?: boolean; contentKey?: string } +): boolean { + const [overflowing, setOverflowing] = React.useState(false); + + React.useLayoutEffect(() => { + if (!element || !enabled) return; + + const measure = () => + setOverflowing( + exceedsLineClamp({ + contentHeight: element.scrollHeight, + clampedHeight: element.clientHeight, + lineHeight: parseFloat(getComputedStyle(element).lineHeight), + maxLines, + }) + ); + measure(); + + // Measured again on the next frame, and again once webfonts have landed. + // + // One synchronous read is not enough, and the failure is silent. A clamped box's own height is + // `lines × line-height` whatever it contains, so the observer below has nothing to fire on — + // which means whatever the *first* read says is the answer forever. And the first read is the + // least trustworthy one: measured in a layout effect, `scrollHeight` on a freshly-clamped box + // can still report the clamped height rather than the content's, and the face the text is set + // in may not have arrived. Both make a four-line claim look like it fits, and the control that + // would have expanded it is never offered. Re-reading costs a single frame. + const frame = requestAnimationFrame(measure); + let cancelled = false; + void document.fonts?.ready?.then(() => { + if (!cancelled) measure(); + }); + + const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure); + observer?.observe(element); + + return () => { + cancelled = true; + cancelAnimationFrame(frame); + observer?.disconnect(); + }; + }, [element, maxLines, enabled, contentKey]); + + return overflowing; +} diff --git a/apps/web/core/debates/browse/use-debate-claim-response.ts b/apps/web/core/debates/browse/use-debate-claim-response.ts new file mode 100644 index 0000000000..c63038e342 --- /dev/null +++ b/apps/web/core/debates/browse/use-debate-claim-response.ts @@ -0,0 +1,61 @@ +'use client'; + +import { type ClaimResponseState, useClaimResponseState } from '~/core/claims/browse/use-claim-response-state'; +import type { DebateClaim } from '~/core/debates/api'; +import { useClaimPositionControl } from '~/core/debates/matchmaking/matchmaking-claim-card'; +import { usePrivySignIn } from '~/core/hooks/use-privy-sign-in'; +import type { Entity } from '~/core/types'; + +/** + * A debate claim's response state and the control that publishes it, resolved together. + * + * `useClaimResponseState` already exists because five surfaces were each assembling the same + * derivation from the same two inputs, and the copies drifted. The layer immediately above it then + * grew the same way: every caller pairs it with `usePrivySignIn` and `useClaimPositionControl`, and + * every one of them has to know that `answersReady` is `isResponseKindResolved && + * isViewerResponseResolved` — a conjunction whose whole purpose is to stop a click publishing the + * wrong vote kind, spelled out by hand at each call site. That is the same bug with a longer fuse, + * so it is written once here. + * + * `offersDebate: false` for every debate surface, and not a parameter. The claims panel, the card + * over the video and the end-of-debate scorecard all sit inside a debate the viewer is already + * watching, so offering them another one is the wrong invitation at the wrong moment — and without + * an offer there is nothing for an account-level match's faces to be borrowed into. + * + * Deliberately not in `core/claims/browse` beside `useClaimResponseState`: `matchmaking-claim-card` + * imports from that directory, so a hook there importing back would close a cycle. + * + * The two claim surfaces outside a debate — the claim page and the explore card — keep wiring this + * up themselves, and should. Neither sits inside a debate, so both leave `offersDebate` at its + * default and *do* make the offer, which is the one thing fixed in place here. The claim page also + * resolves its state a level above the component that builds the control, so it has nothing to + * hand this hook at the point it would be called. + */ +export function useDebateClaimResponse({ + claimId, + spaceId, + row, + entity, +}: { + claimId: string; + spaceId: string; + /** geo-chat's row, where it has one. Null in the spaces it does not index, and before it answers. */ + row: DebateClaim | null; + /** The claim on the graph, which carries the factual flag geo-chat's row would otherwise report. */ + entity: Entity | null; +}): ClaimResponseState & { control: ReturnType } { + const promptSignIn = usePrivySignIn(); + const state = useClaimResponseState({ claimId, spaceId, row, entity }); + + const control = useClaimPositionControl({ + claim: state.claim, + positions: state.positions, + readiness: state.readiness, + answersReady: state.isResponseKindResolved && state.isViewerResponseResolved, + responseBlockedReason: state.responseBlockedReason, + onRequireSignIn: promptSignIn, + offersDebate: false, + }); + + return { ...state, control }; +} diff --git a/apps/web/core/debates/browse/use-open-debater-profile.ts b/apps/web/core/debates/browse/use-open-debater-profile.ts new file mode 100644 index 0000000000..e38f6e4c16 --- /dev/null +++ b/apps/web/core/debates/browse/use-open-debater-profile.ts @@ -0,0 +1,33 @@ +'use client'; + +import * as React from 'react'; + +import type { DebateParticipant } from '~/core/debates/api'; +import { useEntitySidePanel } from '~/core/hooks/use-entity-side-panel'; +import { useSpace } from '~/core/hooks/use-space'; + +/** + * Opens a debater's personal space in the side panel. + * + * A personal space's own id resolves to its "system entity" — an ugly technical record. The space's + * page entity is the real profile, so that is what opens, falling back to the space id while it is + * still being fetched. + * + * Two surfaces name a debater over the video: the row in the corner of their own tile, and the + * header of every claim card. Both are the same link to the same person, so both ask here — a rule + * decided twice is a rule that will eventually disagree with itself. + */ +export function useOpenDebaterProfile(participant: Pick | null | undefined) { + const { openSidePanel } = useEntitySidePanel(); + const { space } = useSpace(participant?.profile_space_id); + const profileEntityId = space?.entity.id || participant?.profile_space_id; + + return React.useCallback( + (event: React.MouseEvent) => { + // The video behind is one large play/pause button. + event.stopPropagation(); + if (participant && profileEntityId) openSidePanel(profileEntityId, participant.profile_space_id, false); + }, + [openSidePanel, participant, profileEntityId] + ); +} diff --git a/apps/web/core/debates/claim-ticker.test.ts b/apps/web/core/debates/claim-ticker.test.ts new file mode 100644 index 0000000000..d457e79317 --- /dev/null +++ b/apps/web/core/debates/claim-ticker.test.ts @@ -0,0 +1,417 @@ +import { describe, expect, it } from 'vitest'; + +import { + CLAIM_LINGER_MS, + backlogWindows, + cardOpacity, + claimHistory, + claimMarkers, + tickerStack, + tickerWindows, +} from './claim-ticker'; +import type { ClaimTiming, TimedClaim } from './claim-timing'; +import { PLAYBACK_END_EPSILON_MS } from './playback-utils'; + +function timed(id: string, timing: ClaimTiming | null, text = `Claim ${id}`): TimedClaim { + return { + id, + text, + spaceId: 'space-1', + blockId: 'block-1', + publishedTiming: null, + relationEntityId: null, + restated: false, + timing, + }; +} + +const confident = (startMs: number, endMs: number): ClaimTiming => ({ + startMs, + endMs, + confidence: 1, + source: 'published', +}); + +/** + * Matched, but not well enough to assert over the video. + * + * 0.28 rather than something nearer the bar on purpose: this is the band where windows collapse to + * a word or two and land in an arbitrary part of the turn, which is what the bar exists to catch. + */ +const unsure = (startMs: number, endMs: number): ClaimTiming => ({ + startMs, + endMs, + confidence: 0.28, + source: 'segment', +}); + +const wholeTurn = (startMs: number, endMs: number): ClaimTiming => ({ + startMs, + endMs, + confidence: 0, + source: 'block', +}); + +describe('tickerWindows', () => { + it('puts the claims in the order they are said', () => { + const windows = tickerWindows([timed('b', confident(18_000, 22_000)), timed('a', confident(10_000, 14_000))]); + + expect(windows.map(window => window.claim.id)).toEqual(['a', 'b']); + // The card's window, not the claim's: 'a' is said 10_000–14_000 and its card shows from 14_000. + expect(windows[0].startMs).toBe(14_000); + }); + + // The behaviour Preston asked for: a card that arrives as the debater *starts* asserts a claim + // the viewer has not heard them make yet. It has to read as "he just said this". + it('shows the card once the claim has been said, not while it is being said', () => { + const [window] = tickerWindows([timed('a', confident(10_000, 14_000))]); + + expect(window.startMs).toBe(14_000); + expect(window.endMs).toBe(14_000 + CLAIM_LINGER_MS); + }); + + // Measuring from the end is what retired the old cap: a window that began at the claim's start + // grew with the claim and had to be clamped so a long one did not sit over the next. + it('gives every card the same time on screen however long the claim ran', () => { + const [long] = tickerWindows([timed('a', confident(0, 30_000))]); + const [short] = tickerWindows([timed('b', confident(0, 1_000))]); + + expect(long.endMs - long.startMs).toBe(CLAIM_LINGER_MS); + expect(short.endMs - short.startMs).toBe(CLAIM_LINGER_MS); + }); + + // The live layer asserts "they are saying this now". A claim the matcher placed roughly is fine + // in the panel and a misquote over the video. + it('excludes claims the matcher was not confident about, and whole-turn fallbacks', () => { + const windows = tickerWindows([ + timed('sure', confident(1_000, 2_000)), + timed('unsure', unsure(3_000, 4_000)), + timed('turn', wholeTurn(0, 30_000)), + timed('untimed', null), + ]); + + expect(windows.map(window => window.claim.id)).toEqual(['sure']); + }); +}); + +describe('tickerStack', () => { + // Cards show 14_000–22_000 and 22_000–30_000: each claim's end, plus the linger. + const windows = tickerWindows([ + timed('first', confident(10_000, 14_000)), + timed('second', confident(18_000, 22_000)), + ]); + + const ids = (playheadMs: number) => tickerStack(windows, playheadMs).map(card => card.window.claim.id); + + it('shows nothing before the first claim is said', () => { + expect(ids(5_000)).toEqual([]); + }); + + it('stays quiet while the claim is still being said', () => { + expect(ids(12_000)).toEqual([]); + }); + + it('shows the claim once it has been said', () => { + expect(ids(14_500)).toEqual(['first']); + }); + + /** + * Two cards do not fit the corner on a tile of any realistic size, and a `justify-end` column + * that overflows overflows *downward* — so the second claim pushed the newest card below the + * tile, where it was cut in half at the seam between the two debaters. + */ + it('replaces the live claim rather than stacking a second one over the video', () => { + const overlapping = tickerWindows([ + timed('first', confident(10_000, 14_000)), + timed('second', confident(14_500, 18_000)), + ]); + + expect(tickerStack(overlapping, 18_500).map(card => card.window.claim.id)).toEqual(['second']); + }); + + // Still ordered newest-last where a caller asks for more than one, so rendered down a column the + // newest sits at the bottom, nearest the name. + it('orders a deeper stack newest last', () => { + const overlapping = tickerWindows([ + timed('first', confident(10_000, 14_000)), + timed('second', confident(14_500, 18_000)), + ]); + + expect(tickerStack(overlapping, 18_500, 2).map(card => card.window.claim.id)).toEqual(['first', 'second']); + }); + + // Cards expire, so the corner is empty most of the time. What has scrolled past is not lost — + // `claimHistory` has it, one hover away. + it('drops a claim once its window closes', () => { + expect(ids(14_000 + CLAIM_LINGER_MS)).toEqual(['second']); + expect(ids(200_000)).toEqual([]); + }); + + // Five claims inside ten seconds is an ordinary turn; the corner has to stay a corner. What is + // pushed off is not lost — `claimHistory` still has it. + it('shows only the most recent few when a turn is busy', () => { + const busy = tickerWindows([ + timed('a', confident(1_000, 2_000)), + timed('b', confident(1_500, 2_500)), + timed('c', confident(2_000, 3_000)), + timed('d', confident(2_500, 3_500)), + ]); + + expect(tickerStack(busy, 3_600, 3).map(card => card.window.claim.id)).toEqual(['b', 'c', 'd']); + }); +}); + +/** + * The backlog takes any claim it can place, not only the ones firm enough to draw a card for. + * + * Sharing one filtered list made the corner disagree with itself: the chip counted "N claims" and + * the list opened on a subset, silently dropping everything the matcher had placed only loosely. + */ +describe('backlogWindows', () => { + const claims = [ + timed('published', confident(10_000, 14_000)), + timed('loose', unsure(18_000, 22_000)), + timed('whole-turn', wholeTurn(0, 30_000)), + timed('unplaced', null), + ]; + + // Ordered by when each claim *finished* being said, so the whole-turn fallback — which ends when + // its turn does — joins the list last rather than at the turn's start. + it('includes claims the live layer will not assert', () => { + expect(backlogWindows(claims).map(w => w.claim.id)).toEqual(['published', 'loose', 'whole-turn']); + }); + + it('still leaves out a claim with no timing at all, which cannot be ordered', () => { + expect(backlogWindows(claims).map(w => w.claim.id)).not.toContain('unplaced'); + }); + + // The live layer is unchanged and stays strict. + it('is a superset of the live windows', () => { + const live = tickerWindows(claims).map(w => w.claim.id); + expect(live).toEqual(['published']); + expect(backlogWindows(claims).map(w => w.claim.id)).toEqual(expect.arrayContaining(live)); + }); +}); + +describe('claimHistory', () => { + const windows = tickerWindows([ + timed('a', confident(10_000, 14_000)), + timed('b', confident(18_000, 22_000)), + timed('c', confident(30_000, 34_000)), + ]); + + it('carries everything said so far, oldest first', () => { + // 'a' finishes at 14_000 and 'b' at 22_000, so at 23_000 both have been said and 'c' has not. + expect(claimHistory(windows, 23_000).map(card => card.window.claim.id)).toEqual(['a', 'b']); + }); + + // The whole point of the backlog: it holds the claims whose cards have expired, which is what + // the live stack no longer does. + it('keeps claims whose window has long closed', () => { + expect(tickerStack(windows, 200_000)).toEqual([]); + expect(claimHistory(windows, 200_000).map(card => card.window.claim.id)).toEqual(['a', 'b', 'c']); + }); + + // A claim the viewer has not reached yet is a spoiler, and "what was said" is a statement about + // what is behind them. + it('stops at the playhead rather than listing the whole debate', () => { + expect(claimHistory(windows, 15_000).map(card => card.window.claim.id)).toEqual(['a']); + expect(claimHistory(windows, 0).map(card => card.window.claim.id)).toEqual([]); + }); + + // The live stack's gradient says "this one is passing", which is the wrong thing to say about + // a list someone has deliberately opened to read. + it('holds every entry at full strength', () => { + expect(claimHistory(windows, 200_000).map(card => card.opacity)).toEqual([1, 1, 1]); + }); +}); + +/** + * A marker sits at the claim's end and seeks just inside the card's window. Seeking to the hash + * itself lands on the first frame of the fade, where the card is fully transparent — and the + * scrubber is mostly used while paused, so the playhead stays there and nothing is ever drawn. + */ +describe('claimMarkers seek target', () => { + const windows = tickerWindows([timed('a', confident(10_000, 14_000))]); + + /** + * Three of 51 debates end on their last claim, and a hash there can only ever be a broken + * promise: the seek has to stay out of the stretch `playbackEnded` calls the end, which is + * earlier than the claim's own window opens, and both the stack and the backlog start at that + * window. So the claim is in neither, wherever the click lands. + */ + it('draws no hash for a claim that ends on the final frame', () => { + expect(claimMarkers([timed('a', confident(10_000, 30_000))], 30_000)).toEqual([]); + }); + + /** + * A claim ending just inside the reachable stretch keeps its hash, and the seek is clamped rather + * than dropped — the card is visible where the click lands, which is the whole contract. + * + * Against the shared threshold, not the duration: `playbackEnded` calls the last + * `PLAYBACK_END_EPSILON_MS` the end, so the first version of this clamp — a millisecond short of + * `timelineMs` — was still inside it and took the corner down exactly as before. + */ + it('clamps rather than drops a claim ending just short of it', () => { + const endMs = 30_000 - PLAYBACK_END_EPSILON_MS - 100; + const [marker] = claimMarkers([timed('a', confident(10_000, endMs))], 30_000); + const [window] = tickerWindows([timed('a', confident(10_000, endMs))]); + + expect(marker.atMs).toBe(endMs); + expect(marker.seekMs).toBeLessThan(30_000 - PLAYBACK_END_EPSILON_MS); + // Short of the full fade, so partly drawn — but on screen, and inside its own window. + expect(marker.seekMs).toBeGreaterThan(marker.atMs); + expect(cardOpacity(window, marker.seekMs)).toBeGreaterThan(0); + }); + + // Every instant of a recording this short is already "the end", so there is nowhere to put the + // viewer where a card would draw. No hash is the honest answer. + it('draws nothing when the whole recording is inside that stretch', () => { + expect(claimMarkers([timed('a', confident(0, 20))], 20)).toEqual([]); + }); + + it('seeks past the fade rather than to the hash itself', () => { + const [marker] = claimMarkers([timed('a', confident(10_000, 14_000))], 100_000); + + expect(marker.atMs).toBe(14_000); + expect(marker.seekMs).toBeGreaterThan(marker.atMs); + // The whole point: the card is actually visible where the click lands. + expect(cardOpacity(windows[0], marker.atMs)).toBe(0); + expect(cardOpacity(windows[0], marker.seekMs)).toBe(1); + }); +}); + +describe('cardOpacity', () => { + const [window] = tickerWindows([timed('a', confident(10_000, 14_000))]); + + it('fades in rather than blinking into place', () => { + expect(cardOpacity(window, 14_000)).toBe(0); + expect(cardOpacity(window, 14_125)).toBeCloseTo(0.5); + expect(cardOpacity(window, 14_400)).toBe(1); + }); + + it('holds at full strength through the middle', () => { + expect(cardOpacity(window, 17_000)).toBe(1); + }); + + it('fades out over the tail of its window', () => { + expect(cardOpacity(window, window.endMs - 750)).toBeCloseTo(0.5); + expect(cardOpacity(window, window.endMs - 1)).toBeLessThan(0.01); + }); + + // A scrub lands wherever it lands; the card has to be as visible as its moment says, not as + // visible as an animation that started when it mounted. + it('is driven by the playhead, so a scrub into the middle lands at full strength', () => { + expect(cardOpacity(window, 17_000)).toBe(1); + // Before the card appears — which now includes while the claim is still being said. + expect(cardOpacity(window, 12_000)).toBe(0); + expect(cardOpacity(window, 99_000)).toBe(0); + }); +}); + +describe('claimMarkers', () => { + it('places every precisely-timed claim on the scrubber', () => { + const markers = claimMarkers( + [timed('b', confident(135_000, 143_000)), timed('a', confident(27_000, 31_000))], + 270_000 + ); + + expect(markers.map(marker => marker.id)).toEqual(['a', 'b']); + }); + + // The hash has to sit where the card appears, or the two ways the timeline talks about one claim + // disagree — the mark is somewhere the card will not show up for several more seconds. + it('marks the end of the claim, where its card appears', () => { + const [marker] = claimMarkers([timed('a', confident(27_000, 31_000))], 310_000); + + expect(marker.atMs).toBe(31_000); + expect(marker.fraction).toBeCloseTo(0.1); + }); + + // The bug this argument exists for. It used to be the latest claim's end, which only matches the + // track the markers are drawn on when the last claim runs to the final second. Measured on live + // debates the gap reached 11% — about 20 seconds out on a 210-second debate. + it('scales by the debate timeline, not by the last claim', () => { + const claims = [timed('a', confident(50_000, 60_000)), timed('b', confident(90_000, 100_000))]; + + const markers = claimMarkers(claims, 200_000); + + expect(markers[0].fraction).toBeCloseTo(0.3); + expect(markers[1].fraction).toBeCloseTo(0.5); + }); + + // A marker is a place to jump to. The middle of a 30s turn is not a place. + it('leaves out claims only known to their turn', () => { + expect(claimMarkers([timed('turn', wholeTurn(0, 30_000))], 270_000)).toEqual([]); + }); + + // The bug Preston found. Markers used to take any match at all, so a debate whose claims all + // matched loosely drew hashes that never produced a card — eight of them on one real debate. A + // hash promises something is there, so it holds the same bar the card does. + it('leaves out a match too loose to show a card for', () => { + expect(claimMarkers([timed('loose', unsure(27_000, 31_000))], 270_000)).toEqual([]); + }); + + /** + * Two claims can end on the same millisecond — `matchClaimWindow` places them on segment + * boundaries, so any two matched to the same window do. Two hashes at one point means the later + * covers the earlier and the first cannot be reached with a pointer, so they become one. + */ + it('draws one hash for several claims that end on the same moment', () => { + const markers = claimMarkers( + [timed('a', confident(10_000, 30_000)), timed('b', confident(20_000, 30_000))], + 270_000 + ); + + expect(markers).toHaveLength(1); + expect(markers[0].count).toBe(2); + // The last in spoken order survives, because that is the one the card will show — see below. + expect(markers[0].id).toBe('b'); + }); + + /** + * The hash's label and the card it lands on have to be the same claim. + * + * `tickerStack` keeps one card, and `.slice(-max)` makes that the *last* of the claims sharing a + * moment. A marker labelled with the first announced one claim and then put a different one on + * screen, which is the one thing a label pointing at a specific sentence must not do. + */ + it('names the claim the card will actually show when several share a moment', () => { + const claims = [timed('a', confident(10_000, 30_000)), timed('b', confident(20_000, 30_000))]; + const [marker] = claimMarkers(claims, 270_000); + const [card] = tickerStack(tickerWindows(claims), marker.seekMs); + + expect(card.window.claim.id).toBe(marker.id); + expect(card.window.claim.text).toBe(marker.text); + }); + + /** + * The invariant the hit-area maths leans on. + * + * `markerHitWidth` sizes each target by its distance to its nearest neighbour, and two markers on + * one point make that zero — same `left`, same width, the later covering the earlier so one claim + * cannot be pressed. The floor there keeps the button clickable; this keeps the case from + * arising, and it is the only producer of `ClaimMarker[]`, so holding it here holds it. + */ + it('never puts two hashes on the same point', () => { + const claims = [ + timed('a', confident(10_000, 30_000)), + timed('b', confident(20_000, 30_000)), + timed('c', confident(40_000, 60_000)), + timed('d', confident(50_000, 60_000)), + timed('e', confident(70_000, 90_000)), + ]; + const fractions = claimMarkers(claims, 270_000).map(marker => marker.fraction); + + expect(new Set(fractions).size).toBe(fractions.length); + expect(fractions).toEqual([...fractions].sort((a, b) => a - b)); + }); + + it('counts a lone claim as standing for itself', () => { + expect(claimMarkers([timed('a', confident(10_000, 30_000))], 270_000)[0].count).toBe(1); + }); + + it('draws nothing before the timeline is known', () => { + expect(claimMarkers([timed('a', confident(1_000, 2_000))], 0)).toEqual([]); + }); +}); diff --git a/apps/web/core/debates/claim-ticker.ts b/apps/web/core/debates/claim-ticker.ts new file mode 100644 index 0000000000..8c2fd4fd42 --- /dev/null +++ b/apps/web/core/debates/claim-ticker.ts @@ -0,0 +1,281 @@ +import { type TimedClaim, isAssertableMoment } from './claim-timing'; +import { PLAYBACK_END_EPSILON_MS } from './playback-utils'; + +/** + * How long a claim card stays up once the debater has finished saying it. + * + * This is now the card's whole life, not a tail added to it — see {@link tickerWindows} — so it is + * longer than the 5s it was when the card also stood through the claim being spoken. Eight keeps + * the time on screen roughly where it was, which is what a viewer actually has to read a sentence + * and press a thumb in. + * + * Long enough to do that, short enough that the next claim is not queuing behind it. Claims in the + * test debate land 5–9s apart at their closest, so a couple will overlap at this length — which is + * what the stack is for, and reads as a feed rather than a queue. + */ +export const CLAIM_LINGER_MS = 8_000; + +/** + * A claim eligible to surface over the video, with the window its *card* is on screen for. + * + * Not the claim's own times — those are on `claim.timing`, and the scrubber marker uses them to + * jump to where it was said. These two say when the card appears and disappears. + */ +export type TickerWindow = { claim: TimedClaim; startMs: number; endMs: number }; + +/** + * The claims eligible to surface over the video, in the order they are said. + * + * **A card appears when the claim has been said, not when it starts.** Anchored to the start it + * arrived while the debater was still mid-sentence, so the card asserted a claim the viewer had not + * heard them make yet — it read as the app putting words in their mouth, and the reader was being + * asked to agree with something still being argued. Anchored to the end it reads as "he just said + * this — do you agree?", which is the question this layer exists to ask. + * + * That also retires the cap this used to need. A window running from the claim's start had to be + * clamped so a long claim did not leave its card up over the next one; measured from the end, every + * window is {@link CLAIM_LINGER_MS} long whatever the claim did. + * + * Only confidently-placed claims qualify. A claim the matcher put in roughly the right region is + * fine in a list and not fine over the video, where the card asserts *this is what he just said* — + * and being wrong about that misquotes a real person. Those claims still appear in the panel. + */ +export function tickerWindows(claims: TimedClaim[]): TickerWindow[] { + return windowsFor(claims, claim => isAssertableMoment(claim.timing)); +} + +/** + * The same windows for the backlog, which takes any claim it can place at all. + * + * The live layer and the backlog are asking different questions. A card asserts "they said this, at + * this moment", so it needs a moment firm enough to stand behind — {@link isAssertableMoment}. The + * backlog only claims "they have said this already", and a whole-turn fallback answers that + * perfectly well: its window ends when the turn does, so the claim joins the list once the turn is + * over, which is exactly when it became true. + * + * Built separately rather than filtered from one list, because sharing the list is what made the + * corner quietly disagree with itself: the chip counted "N claims" and the list opened on a subset, + * missing every claim the matcher had placed only loosely. + * + * How much that hides depends entirely on how many claims carry published offsets, which is a + * moving number — a backfill raises it, every newly recorded debate lowers it, and it reaches + * nothing-to-hide only when the extractor emits offsets itself (GEO-2958). No count is recorded + * here for that reason: it would be wrong by the next debate, and a comment that has to be right + * about a live corpus is a comment that will be wrong. + */ +export function backlogWindows(claims: TimedClaim[]): TickerWindow[] { + return windowsFor(claims, claim => claim.timing !== null); +} + +function windowsFor(claims: TimedClaim[], include: (claim: TimedClaim) => boolean): TickerWindow[] { + return claims + .filter(include) + .map(claim => { + const timing = claim.timing as NonNullable; + return { claim, startMs: timing.endMs, endMs: timing.endMs + CLAIM_LINGER_MS }; + }) + .sort((a, b) => a.startMs - b.startMs); +} + +/** + * How many cards are live over the video at once, before the older one is dropped. + * + * One. Two cards are taller than the corner has room for on a tile of any realistic size, and a + * flex column that overflows a `justify-end` box overflows *downward* — so the second claim pushed + * the newest card below the tile, where it was cut in half at the seam between the two debaters. + * + * Making room instead would mean a shorter card or a smaller type size, and the card is already at + * the frame's size. So the older claim now leaves as the new one arrives, which is also the more + * honest thing for a live layer to do: the corner shows what is being said. Nothing is lost — the + * backlog is one hover or one tap away, and that is where reading back through it belongs. + */ +const MAX_STACKED_CARDS = 1; + +/** Long enough to register as arriving rather than blinking into place. */ +const FADE_IN_MS = 250; +/** The tail of a card's window, over which it fades out instead of vanishing. */ +const FADE_OUT_MS = 1_500; + +/** + * How visible a card is at this moment, in [0, 1]. + * + * Driven by the playhead rather than a CSS animation, because the playhead is the source of truth + * and it can jump: a viewer who scrubs back into the middle of a claim should find the card at full + * strength, not mid-way through an animation that started when the element mounted. + */ +export function cardOpacity(window: TickerWindow, playheadMs: number): number { + if (playheadMs < window.startMs || playheadMs >= window.endMs) return 0; + + const sinceStart = playheadMs - window.startMs; + if (sinceStart < FADE_IN_MS) return sinceStart / FADE_IN_MS; + + const untilEnd = window.endMs - playheadMs; + if (untilEnd < FADE_OUT_MS) return untilEnd / FADE_OUT_MS; + + return 1; +} + +export type StackedCard = { window: TickerWindow; opacity: number }; + +/** + * The claims on screen right now, oldest first. + * + * Rendered in this order down a column, the newest card sits at the bottom, nearest the debater's + * name, and earlier ones ride up above it — the shape of a live chat rather than a dialog. + * + * A card expires with its window and the corner goes quiet again. Cards were briefly made permanent + * so there was always something to go back to; that is what {@link claimHistory} is for now, and it + * is a better answer, because it does not cost the video a permanently occupied corner to get it. + * + * Answering does *not* retire a card early. It used to, which took the side the viewer had just + * chosen off the screen before they saw it land; the filled icon is the acknowledgement, and the + * window runs out on its own soon enough. + */ +export function tickerStack( + windows: TickerWindow[], + playheadMs: number, + max: number = MAX_STACKED_CARDS +): StackedCard[] { + return ( + spokenSoFar(windows, playheadMs) + .filter(window => playheadMs < window.endMs) + // Drop the oldest when there are more than fit, so what is on screen is what was just said. + .slice(-max) + .map(window => ({ window, opacity: cardOpacity(window, playheadMs) })) + ); +} + +/** + * Every claim said so far, oldest first — the list the corner opens into on hover. + * + * Not bounded by the card windows: this is the backlog, and the whole point of it is the claims + * whose moment has passed. Bounded by the playhead instead, because a claim the viewer has not + * reached yet is a spoiler and "scroll back through what was said" is only a statement about what + * is behind them. + * + * All at full strength: the live stack's gradient says "this one is passing", which is the wrong + * thing to say about a list someone has deliberately opened to read. + */ +export function claimHistory(windows: TickerWindow[], playheadMs: number): StackedCard[] { + return spokenSoFar(windows, playheadMs).map(window => ({ window, opacity: 1 })); +} + +/** `windows` arrives sorted by {@link tickerWindows}, so this stays in spoken order. */ +function spokenSoFar(windows: TickerWindow[], playheadMs: number): TickerWindow[] { + return windows.filter(window => playheadMs >= window.startMs); +} + +export type ClaimMarker = { + id: string; + text: string; + /** Where the hash sits: the moment the claim finished being said. */ + atMs: number; + /** + * Where a click on it jumps to, which is *not* where it sits. + * + * A card's window opens at `atMs` and fades in over {@link FADE_IN_MS}, so its opacity at that + * exact instant is zero. The scrubber is mostly used while paused, where the playhead then stays + * put — so seeking to the hash showed the viewer nothing at all, on the one interaction whose + * entire purpose is to show them that claim. Landing just inside the window costs a quarter of a + * second of accuracy and is the difference between a card and a blank corner. + * + * Held short of where the player calls the debate over, because it takes the whole corner down + * at that point — so an offset that ran past produced exactly the blank it was added to prevent. + * "Over" is {@link PLAYBACK_END_EPSILON_MS} short of the duration, not the duration itself: the + * first version of this clamp used `timelineMs - 1`, still landed inside that window, and took + * the corner down exactly as before. The shared constant is what stops the two files disagreeing + * about where the end is. + * + * A claim whose window opens after the last seekable instant gets no hash at all — see + * {@link claimMarkers}. Clamping alone cannot rescue that one: the seek would land *before* the + * window, and both the live stack and the backlog are bounded by `playheadMs >= startMs`, so the + * claim would be in neither. Measured across 51 debates with published offsets, three end on + * their last claim; the median has 45 seconds of tail after it. + */ + seekMs: number; + fraction: number; + /** + * How many claims this one hash stands for. + * + * Usually one. More when several claims finish in the same segment: {@link matchClaimWindow} + * places a claim on segment boundaries, so two claims matched to the same window end on the same + * millisecond and would otherwise draw two hashes at one point — where the later covers the + * earlier and the first can never be reached with a pointer. Measured over the corpus as the + * matcher places it, rather than from published offsets, 8 of 563 assertable claims share a + * moment with a differently-worded claim, so this is ordinary rather than exotic. + * + * One hash per moment is also the truer model. The stack shows one card at a time, so seeking + * here surfaces the newest of them and leaves the rest a scroll away in the backlog — which is + * what the hash was promising. `id` and `text` are that newest claim's for the same reason: they + * are what the hash's label and tooltip say, and a label has to name the card it lands on. + */ + count: number; +}; + +/** + * Where each claim sits on the scrubber, as a fraction of the debate's length. + * + * Marked at the claim's *end*, like the card — see {@link tickerWindows}. A hash that sat at the + * start put the mark somewhere the card would not appear for several more seconds, so the two ways + * the timeline talks about one claim disagreed. Jumping to a hash now lands where its card does. + * + * `timelineMs` is the debate's own timeline, which is what the scrubber track measures. It used to + * be the latest claim's end, which is only the same number when the last claim runs to the final + * second — otherwise every marker was stretched rightwards by the difference. Measured across live + * debates that reached 11%, roughly 20 seconds out on a 210-second debate. + * + * Same bar as the card, {@link isAssertableMoment}. Markers used to take any match at all, on the + * reasoning that a marker is only an offer to jump and landing a second or two off costs nothing. + * That was wrong in a way a real debate showed plainly: "A man should always pay for the first + * date" matched all twelve of its claims between 0.37 and 0.52, so the scrubber drew eight hashes + * and not one of them ever produced a card. A hash is a promise that something is there, and eight + * broken promises read as the feature being broken rather than as the matcher being unsure. + * + * The cost is that a debate we cannot place confidently now shows no hashes, which is the honest + * answer — its claims are still in the panel, where a list makes no claim about when. + */ +export function claimMarkers(claims: TimedClaim[], timelineMs: number): ClaimMarker[] { + // The last instant the player does not already call the end of the debate. Below zero the whole + // recording sits inside that window, so no seek can put a card up and a hash would be promising + // something nothing can deliver. It also stands in for the old `timelineMs <= 0` guard, which + // this is strictly narrower than, and keeps `fraction` off a zero denominator. + const lastSeekableMs = timelineMs - PLAYBACK_END_EPSILON_MS - 1; + if (lastSeekableMs < 0) return []; + + return ( + claims + .filter(claim => isAssertableMoment(claim.timing)) + // And reachable: a window that opens at or after the last seekable instant cannot be entered. + // `spokenSoFar` admits a claim only from `startMs` onwards, and `startMs` is the claim's end, + // so a seek short of it shows nothing in the live stack *or* the backlog. A hash is a promise + // that something is there; for these there is nowhere to land, so there is no hash. + .filter(claim => (claim.timing as NonNullable).endMs < lastSeekableMs) + .map(claim => { + const timing = claim.timing as NonNullable; + return { + id: claim.id, + text: claim.text, + atMs: timing.endMs, + // Just inside the card's window, and short of where the player calls it over — see + // `seekMs` for both halves of that. + seekMs: Math.min(timing.endMs + FADE_IN_MS, lastSeekableMs), + fraction: Math.max(0, Math.min(1, timing.endMs / timelineMs)), + count: 1, + }; + }) + .sort((a, b) => a.atMs - b.atMs) + // One hash per moment — see {@link ClaimMarker.count}. Sorted first, so claims sharing a + // moment are adjacent, and the *last* of them is the survivor: `tickerStack` keeps one card + // and takes it from the end of the group, so this is the sentence a click actually puts on + // screen. Taking the first instead left the hash labelled with one claim and showing another. + .reduce((kept, marker) => { + const last = kept[kept.length - 1]; + if (last && last.atMs === marker.atMs) { + kept[kept.length - 1] = { ...marker, count: last.count + 1 }; + return kept; + } + kept.push(marker); + return kept; + }, []) + ); +} diff --git a/apps/web/core/debates/claim-timing.test.ts b/apps/web/core/debates/claim-timing.test.ts new file mode 100644 index 0000000000..d19bceb733 --- /dev/null +++ b/apps/web/core/debates/claim-timing.test.ts @@ -0,0 +1,497 @@ +import { describe, expect, it } from 'vitest'; + +import type { DebateTranscriptSegment } from './api'; +import { + type ClaimTiming, + LIVE_TIMING_CONFIDENCE, + claimsInSpokenOrder, + contentWords, + findBlockWindow, + formatTimecode, + isAssertableMoment, + matchClaimWindow, + resolveClaimTimings, + scoreWindow, + sortClaimsBySpokenOrder, +} from './claim-timing'; +import type { TranscriptBlock, TranscriptClaim } from './transcript-claims'; + +/** + * One turn of a real published debate, verbatim. + * + * Preston's third turn of "Trump's second term is making America more authoritarian" + * (`01a0a607-72dc-7cb0-9bf6-ebba15e97b67`), 2:00–2:45, with the three claims the extractor pulled + * out of it. Real data rather than invented sentences, because the thing under test is whether an + * extractor's *paraphrase* can be matched back to speech — and a hand-written fixture would quietly + * be written to match. + */ +function segment(index: number, startMs: number, endMs: number, text: string): DebateTranscriptSegment { + return { + id: `segment-${index}`, + participant_slot: 1, + position: false, + position_label: 'Disagree', + sequence_index: index, + start_ms: startMs, + end_ms: endMs, + text, + metadata: {}, + created_at: '2026-09-15T17:35:12.722937Z', + }; +} + +const SEGMENTS: DebateTranscriptSegment[] = [ + segment(0, 120000, 120530, 'Yeah, I totally agree with the'), + segment(1, 120530, 124030, 'intention of the founding'), + segment(2, 124030, 126080, 'fathers and the checks and'), + segment(3, 126080, 127720, 'balances comment.'), + segment(4, 127720, 129670, 'I still stand by that this'), + segment(5, 129670, 131520, 'trend has been continuing.'), + segment(6, 131520, 134600, 'It really comes down to the'), + segment(7, 134600, 138450, 'Supreme Court not providing'), + segment(8, 138450, 140960, 'those checks anymore.'), + segment(9, 140960, 143140, 'And you also have executive'), + segment(10, 143140, 145230, 'branches or governmental'), + segment(11, 145230, 147890, 'branches sidestepping the'), + segment(12, 147890, 149000, 'powers of Congress'), + segment(13, 149000, 150520, 'and suing companies.'), + segment(14, 150520, 154020, 'You look at the SEC under Gensler'), + segment(15, 154020, 157450, 'and his attacks on the crypto'), + segment(16, 157450, 159080, 'industry and then the'), + segment(17, 159080, 160640, 'ensuing legal battles that came'), + segment(18, 160640, 160960, 'up.'), + segment(19, 160960, 162210, "I'm not necessarily saying that"), + segment(20, 162210, 163080, "this isn't happening now."), + segment(21, 163080, 164140, "I'm saying that it's more of a"), + segment(22, 164140, 165000, "trend that's been happening."), +]; + +const BLOCK_TEXT = + 'Yeah, I totally agree with the intention of the founding fathers and the checks and balances comment. ' + + 'I still stand by that this trend has been continuing. It really comes down to the Supreme Court not ' + + 'providing those checks anymore. And you also have executive branches or governmental branches ' + + 'sidestepping the powers of Congress and suing companies. You look at the SEC under Gensler and his ' + + "attacks on the crypto industry and then the ensuing legal battles that came up. I'm not necessarily " + + "saying that this isn't happening now. I'm saying that it's more of a trend that's been happening."; + +const BLOCK: TranscriptBlock = { id: 'block-1', authorSpaceId: 'author-1', text: BLOCK_TEXT }; + +function claim(id: string, text: string, overrides: Partial = {}): TranscriptClaim { + return { + id, + text, + spaceId: 'space-1', + blockId: 'block-1', + publishedTiming: null, + relationEntityId: null, + restated: false, + ...overrides, + }; +} + +const SUPREME_COURT = claim( + 'claim-supreme-court', + 'The Supreme Court is no longer providing sufficient checks on executive power' +); +const SIDESTEPPING = claim( + 'claim-sidestepping', + 'Executive branches of the government are sidestepping the powers of Congress by suing companies directly' +); +const SEC = claim( + 'claim-sec', + 'The SEC under Gary Gensler has taken action against the crypto industry, resulting in legal battles' +); + +describe('findBlockWindow', () => { + it('locates the turn exactly, because a block is its segments joined', () => { + expect(findBlockWindow(BLOCK_TEXT, SEGMENTS)).toEqual({ startIndex: 0, endIndex: 22 }); + }); + + it('ignores punctuation and whitespace differences', () => { + const reflowed = BLOCK_TEXT.replace(/\s+/g, ' ').replace(/,/g, ''); + expect(findBlockWindow(reflowed, SEGMENTS)).toEqual({ startIndex: 0, endIndex: 22 }); + }); + + it('still places a turn whose tail was truncated after publishing', () => { + const truncated = SEGMENTS.slice(0, 16) + .map(value => value.text) + .join(' '); + expect(findBlockWindow(truncated, SEGMENTS)).toEqual({ startIndex: 0, endIndex: 15 }); + }); + + it('returns null rather than guessing when the text is from another debate', () => { + expect(findBlockWindow('Housing policy is downstream of interest rates.', SEGMENTS)).toBeNull(); + expect(findBlockWindow('', SEGMENTS)).toBeNull(); + }); +}); + +describe('matchClaimWindow', () => { + const window = { startIndex: 0, endIndex: 22 }; + + it('places each claim on the sentence that says it', () => { + const supremeCourt = matchClaimWindow(SUPREME_COURT.text, SEGMENTS, window); + expect(supremeCourt?.startMs).toBe(134600); + expect(supremeCourt?.endMs).toBe(143140); + + const sidestepping = matchClaimWindow(SIDESTEPPING.text, SEGMENTS, window); + expect(sidestepping?.startMs).toBe(140960); + expect(sidestepping?.endMs).toBe(150520); + + const sec = matchClaimWindow(SEC.text, SEGMENTS, window); + expect(sec?.startMs).toBe(150520); + expect(sec?.endMs).toBe(160640); + }); + + it('is confident enough about a well-matched claim to surface it live', () => { + const supremeCourt = matchClaimWindow(SUPREME_COURT.text, SEGMENTS, window); + expect(supremeCourt?.confidence).toBeGreaterThanOrEqual(LIVE_TIMING_CONFIDENCE); + }); + + it('prefers the tightest window that accounts for the claim, not the widest', () => { + const matched = matchClaimWindow(SUPREME_COURT.text, SEGMENTS, window); + // The whole turn contains these words too; a window that wide would be useless. + expect((matched?.endMs ?? 0) - (matched?.startMs ?? 0)).toBeLessThan(15_000); + }); + + it('scores a claim from a different debate far below the live bar', () => { + const unrelated = matchClaimWindow('Remote work made the housing squeeze worse.', SEGMENTS, window); + expect(unrelated?.confidence ?? 0).toBeLessThan(LIVE_TIMING_CONFIDENCE); + }); +}); + +describe('resolveClaimTimings', () => { + const claims = [SUPREME_COURT, SIDESTEPPING, SEC]; + + /** + * A claim stated in two turns is one deduped row carrying one turn's block and offsets, so any + * moment resolved for it would be one of two with no way to tell which. Declining keeps it in the + * panel and out of the live layer, where a card would otherwise be drawn over the wrong face. + */ + it('gives no timing to a claim stated in two turns, published offsets included', () => { + const timings = resolveClaimTimings({ + claims: [ + claim('restated', 'Said in two turns', { publishedTiming: { startMs: 1_000, endMs: 2_000 }, restated: true }), + claim('ordinary', 'Said once', { publishedTiming: { startMs: 3_000, endMs: 4_000 } }), + ], + blocks: [], + segments: [], + }); + + expect(timings.has('restated')).toBe(false); + expect(timings.get('ordinary')).toMatchObject({ startMs: 3_000, source: 'published' }); + }); + + it('prefers published timecodes over matching, and says so', () => { + const published = claim('claim-published', SEC.text, { publishedTiming: { startMs: 1000, endMs: 2000 } }); + + const timings = resolveClaimTimings({ claims: [published], blocks: [BLOCK], segments: SEGMENTS }); + + expect(timings.get('claim-published')).toEqual({ + startMs: 1000, + endMs: 2000, + confidence: 1, + source: 'published', + }); + }); + + it('matches against the transcript when nothing is published', () => { + const timings = resolveClaimTimings({ claims, blocks: [BLOCK], segments: SEGMENTS }); + + expect(timings.size).toBe(3); + for (const [, timing] of timings) { + expect(timing.source).toBe('segment'); + expect(timing.startMs).toBeGreaterThanOrEqual(120000); + expect(timing.endMs).toBeLessThanOrEqual(165000); + } + }); + + it('falls back to the whole turn when the claim shares no vocabulary with it', () => { + const offTopic = claim('claim-off-topic', 'Interest rates drove the housing squeeze in coastal cities.'); + + const timings = resolveClaimTimings({ claims: [offTopic], blocks: [BLOCK], segments: SEGMENTS }); + + expect(timings.get('claim-off-topic')).toEqual({ + startMs: 120000, + endMs: 165000, + confidence: 0, + source: 'block', + }); + }); + + it('leaves a claim untimed when its turn cannot be located, rather than guessing', () => { + const orphan = claim('claim-orphan', SEC.text, { blockId: 'block-missing' }); + + const timings = resolveClaimTimings({ claims: [orphan], blocks: [BLOCK], segments: SEGMENTS }); + + expect(timings.has('claim-orphan')).toBe(false); + }); + + it('times nothing before the transcript has loaded', () => { + expect(resolveClaimTimings({ claims, blocks: [BLOCK], segments: [] }).size).toBe(0); + }); + + it('reads segments in time order even when the API returns them shuffled', () => { + const shuffled = [...SEGMENTS].reverse(); + + const timings = resolveClaimTimings({ claims, blocks: [BLOCK], segments: shuffled }); + + expect(timings.get(SUPREME_COURT.id)?.startMs).toBe(134600); + }); +}); + +describe('claimsInSpokenOrder', () => { + it('reorders claims the graph returned arbitrarily into the order they were said', () => { + // The order below is the order the API actually returns these three, which is not chronological + // — relations are published with random positions. + const asReturned = [SIDESTEPPING, SUPREME_COURT, SEC]; + const timings = resolveClaimTimings({ claims: asReturned, blocks: [BLOCK], segments: SEGMENTS }); + + expect(claimsInSpokenOrder(asReturned, timings).map(value => value.id)).toEqual([ + SUPREME_COURT.id, + SIDESTEPPING.id, + SEC.id, + ]); + }); + + it('keeps untimed claims at the end in their incoming order', () => { + const orphanA = claim('orphan-a', 'one', { blockId: 'missing' }); + const orphanB = claim('orphan-b', 'two', { blockId: 'missing' }); + const claims = [orphanA, SEC, orphanB]; + const timings = resolveClaimTimings({ claims, blocks: [BLOCK], segments: SEGMENTS }); + + expect(claimsInSpokenOrder(claims, timings).map(value => value.id)).toEqual([SEC.id, 'orphan-a', 'orphan-b']); + }); +}); + +describe('sortClaimsBySpokenOrder', () => { + const timing = (startMs: number, confidence: number, source: ClaimTiming['source']): ClaimTiming => ({ + startMs, + endMs: startMs + 4_000, + confidence, + source, + }); + + it('orders by when each claim was said', () => { + const timings = new Map([ + ['late', timing(30_000, 1, 'published')], + ['early', timing(10_000, 1, 'published')], + ]); + + expect(sortClaimsBySpokenOrder([{ id: 'late' }, { id: 'early' }], timings).map(c => c.id)).toEqual([ + 'early', + 'late', + ]); + }); + + // A loose match is not firm enough to print a timecode, but a list still has to put the claim + // somewhere, and roughly-when beats the turn it happens to sit in. + it('uses a match too loose to state as a timecode', () => { + const timings = new Map([ + ['loose', timing(10_000, 0.28, 'segment')], + ['firm', timing(30_000, 1, 'published')], + ]); + + expect(isAssertableMoment(timings.get('loose')!)).toBe(false); + expect(sortClaimsBySpokenOrder([{ id: 'firm' }, { id: 'loose' }], timings).map(c => c.id)).toEqual([ + 'loose', + 'firm', + ]); + }); + + // Every claim of one turn carries that turn's window, so they tie. Relation `position` is random, + // so without a tiebreak the order inside a turn is a shuffle. + it('breaks a shared turn with the tiebreak rather than arrival order', () => { + const turn = timing(60_000, 0, 'block'); + const timings = new Map([ + ['a', turn], + ['b', turn], + ['c', turn], + ]); + const rank = new Map([ + ['a', 2], + ['b', 0], + ['c', 1], + ]); + const byRank = (x: { id: string }, y: { id: string }) => rank.get(x.id)! - rank.get(y.id)!; + + expect(sortClaimsBySpokenOrder([{ id: 'a' }, { id: 'b' }, { id: 'c' }], timings, byRank).map(c => c.id)).toEqual([ + 'b', + 'c', + 'a', + ]); + }); + + it('puts a claim with no moment at all last', () => { + const timings = new Map([['timed', timing(90_000, 1, 'published')]]); + + expect(sortClaimsBySpokenOrder([{ id: 'untimed' }, { id: 'timed' }], timings).map(c => c.id)).toEqual([ + 'timed', + 'untimed', + ]); + }); +}); + +describe('scoreWindow', () => { + // Found while planning the backfill: two claims scored 1.05 and 1.03, which a function + // documented as returning [0, 1] cannot do. A repeated claim word was counted once per + // occurrence against a *set* of window words, so `hits` could exceed the window's distinct-word + // count and push the precision term above 1 — inflating a repetitive claim over a tighter match. + it('never scores above 1, however often a claim repeats a word', () => { + const repetitive = ['ai', 'ai', 'ai', 'therapist']; + + expect(scoreWindow(repetitive, 'ai therapist')).toBeLessThanOrEqual(1); + }); + + it('scores a perfect, tight match at 1', () => { + expect(scoreWindow(['executive', 'orders'], 'executive orders')).toBe(1); + }); + + it('scores nothing for a window sharing no content words', () => { + expect(scoreWindow(['executive', 'orders'], 'the weather today')).toBe(0); + }); + + /** + * The polarity rule, through the other door. + * + * `wasn't` used to tokenise as one opaque word — neither `was` nor `not` — so a claim's `not` had + * nothing to match and the negated window carried a spare content word that cut its precision. + * The affirmative therefore *outscored* the negation, 0.6667 to 0.6111, and the matcher would + * place the claim over the speaker saying the reverse. + */ + it('scores a negation above its own opposite, however the speaker contracted it', () => { + const claim = contentWords('vaccination was not safe'); + + for (const spoken of ["vaccination wasn't safe", 'vaccination was not safe', 'vaccination wasn\u2019t safe']) { + expect(scoreWindow(claim, spoken)).toBeGreaterThan(scoreWindow(claim, 'vaccination was safe')); + } + }); + + // `can't` and `won't` leave a two-letter stem, which the length floor drops — the negator is what + // has to survive. + it('recovers the negator from contractions that do not split cleanly', () => { + expect(contentWords("we can't regulate it")).toContain('not'); + expect(contentWords("we won't regulate it")).toContain('not'); + }); + + /** + * `cannot` is the same fault with no apostrophe to find it by, and it ran in both directions: + * whichever of the three spellings each side used, the affirmative outscored the negation. + */ + it('reads every spelling of a negated "can" the same way', () => { + const spellings = ['vaccination cannot be safe', "vaccination can't be safe", 'vaccination can not be safe']; + + for (const claimText of spellings) { + const claim = contentWords(claimText); + for (const spoken of spellings) { + expect(scoreWindow(claim, spoken)).toBe(1); + } + expect(scoreWindow(claim, 'vaccination can be safe')).toBeLessThan(1); + } + }); + + /** + * STOPWORDS holds `it` and `that` but not `it's` and `that's`, so the apostrophe smuggled them + * past as content words — and only on the speech side, since the extractor does not contract. + * Each one was a distinct window word no claim could match, cutting that window's precision. + */ + it('does not let a contracted stopword count as content', () => { + expect(contentWords("it's true")).toEqual(['true']); + expect(contentWords("that's true")).toEqual(['true']); + expect(contentWords("we're right")).toEqual(['right']); + expect(contentWords("there's a problem")).toEqual(['problem']); + expect(contentWords("i'm certain")).toEqual(['certain']); + }); + + // Possessives fold to the thing possessed, which is the word that carries the meaning. + it('reads a possessive as the word it is built from', () => { + // Order follows the sentence, so compare the sets rather than the sequences. + expect(contentWords("a person's health").sort()).toEqual(contentWords('the health of a person').sort()); + }); + + // The quarter-weight precision term exists to break ties towards the tightest window: a wide + // window contains everything a narrow one does, so without it the widest always wins. + it('prefers the tighter of two windows that both contain the claim', () => { + const claim = ['executive', 'orders']; + const tight = scoreWindow(claim, 'executive orders'); + const wide = scoreWindow(claim, 'executive orders and a great many other unrelated matters besides'); + + expect(tight).toBeGreaterThan(wide); + }); +}); + +describe('isAssertableMoment', () => { + const at = (confidence: number, source: ClaimTiming['source']): ClaimTiming => ({ + startMs: 1_000, + endMs: 5_000, + confidence, + source, + }); + + it('admits a published offset and a confident match', () => { + expect(isAssertableMoment(at(1, 'published'))).toBe(true); + expect(isAssertableMoment(at(0.62, 'segment'))).toBe(true); + }); + + // The bar is 0.40, from reading 59 matched claims against their transcripts — the 0.40–0.44 band + // was right nine times out of nine, so a claim scoring 0.42 is a claim the old 0.55 threw away. + it('admits the band the calibration cleared', () => { + expect(isAssertableMoment(at(0.42, 'segment'))).toBe(true); + expect(isAssertableMoment(at(0.4, 'segment'))).toBe(true); + }); + + // Below the bar the windows collapse to a word or two in an arbitrary part of the turn. The + // panel's timecode used to admit these, printing a time to the second on evidence the live layer + // would not draw at all. + it('refuses a match too loose for the live layer', () => { + expect(isAssertableMoment(at(0.34, 'segment'))).toBe(false); + expect(isAssertableMoment(at(0.17, 'segment'))).toBe(false); + }); + + it('refuses a whole-turn fallback and a claim with no moment', () => { + expect(isAssertableMoment(at(0, 'block'))).toBe(false); + expect(isAssertableMoment(null)).toBe(false); + }); +}); + +describe('formatTimecode', () => { + it('formats a timecode the way a player does', () => { + expect(formatTimecode(0)).toBe('0:00'); + expect(formatTimecode(9_400)).toBe('0:09'); + expect(formatTimecode(134_600)).toBe('2:15'); + expect(formatTimecode(270_000)).toBe('4:30'); + }); +}); + +/** + * The rule the stopword list has to keep: a word whose opposite is scored must be scored too. + * Drop one half of a pair and the matcher cannot tell "X is safe" from "X is not safe", and will + * place the claim over whichever window it meets first. + */ +describe('contentWords keeps both halves of a polarity pair', () => { + const pairs: [string, string][] = [ + ['not', 'never'], + ['more', 'less'], + ['most', 'least'], + ['many', 'few'], + ]; + + for (const [word, opposite] of pairs) { + it(`scores "${word}" as well as "${opposite}"`, () => { + expect(contentWords(`funding is ${word} available`)).toContain(word); + expect(contentWords(`funding is ${opposite} available`)).toContain(opposite); + }); + } + + // The claim and its reverse must not reduce to the same set, which is what let the matcher + // choose between them arbitrarily. + it('tells a claim apart from its negation', () => { + expect(contentWords('vaccination was safe')).not.toEqual(contentWords('vaccination was not safe')); + }); + + // Modality is dropped on purpose, and symmetrically, so no pair is broken. + it('drops every modal, so none outranks another', () => { + for (const modal of ['can', 'could', 'may', 'might', 'must', 'should', 'will', 'would']) { + expect(contentWords(`funding ${modal} arrive`)).not.toContain(modal); + } + }); +}); diff --git a/apps/web/core/debates/claim-timing.ts b/apps/web/core/debates/claim-timing.ts new file mode 100644 index 0000000000..0dff09260e --- /dev/null +++ b/apps/web/core/debates/claim-timing.ts @@ -0,0 +1,510 @@ +import type { DebateTranscriptSegment } from './api'; +import type { TranscriptBlock, TranscriptClaim } from './transcript-claims'; + +/** + * Where a claim's moment on the debate timeline came from, best first. + * + * - `published` — read off the block → claim relation entity. Exact, and free. + * - `segment` — matched against the Whisper transcript. Right span, a second or two loose at the + * edges, and it carries a confidence. + * - `block` — only the turn is known, so the claim is placed across the whole turn. Coarse but + * never wrong. + * + * The resolver tries them in that order, so a debate published before timecodes existed still gets + * an answer and silently improves the day offsets land for it. Nothing above this module knows + * which source answered; surfaces read `confidence` and decide how much to trust it. + */ +export type ClaimTimingSource = 'published' | 'segment' | 'block'; + +export type ClaimTiming = { + /** Milliseconds from the start of the debate timeline. */ + startMs: number; + endMs: number; + /** + * How much to trust it: 1 for published, the match score for `segment`, 0 for `block`. + * See {@link LIVE_TIMING_CONFIDENCE}. + */ + confidence: number; + source: ClaimTimingSource; +}; + +export type TimedClaim = TranscriptClaim & { timing: ClaimTiming | null }; + +/** + * The bar a timing has to clear before a surface may state it — see {@link isAssertableMoment}. + * + * 0.40, from reading 59 matched claims across five debates against their transcripts — printed by + * `scripts/dump-match-windows.ts`, which puts each claim beside the window it matched and the whole + * turn it came from — plus the 13 hand-checked claims `scripts/calibrate-match-score.ts` measures. Everything from 0.40 to 0.55 was right + * 20 times out of 22 — nine of nine in the 0.40–0.44 band alone — so the 0.55 this started at was + * discarding claims placed exactly correctly. Below 0.30 it degrades sharply: windows collapse to + * one or two content words and land in an arbitrary part of the turn. + * + * Worth knowing what the number is actually measuring. The score is containment of the claim's + * content words, so it tracks *how closely the extractor echoed the speech*, not how likely the + * match is right — a faithful paraphrase of a long sentence scores low for using different words. + * That is why it separates so weakly across its middle, and why the one error above this bar is one + * no threshold catches: the claim's specifics were not in its turn at all. + * + * The matcher is the fallback. Where an LLM has read the turn and chosen the span, or the extractor + * has published one, that answer is used instead — see GEO-2958. + */ +export const LIVE_TIMING_CONFIDENCE = 0.4; + +/** + * Whether a timing is firm enough to *state* — "Said at 2:29", a card over the video. + * + * One predicate for every surface that puts a moment in front of a reader, because they were + * disagreeing. The live layer asked for {@link LIVE_TIMING_CONFIDENCE} while the panel's timecode + * asked only that the claim was matched at all, so a 0.40 match — judged too loose to draw over + * the video — still printed a time to the second, and read exactly as authoritative as a published + * offset. The weaker evidence was making the more precise claim. + * + * Ordering deliberately does not go through here. A list has to put a claim *somewhere*, and a + * rough position in a list asserts far less than a timestamp does, so + * {@link sortClaimsBySpokenOrder} uses any usable match and leaves the rest grouped by their turn. + */ +export function isAssertableMoment(timing: ClaimTiming | null): timing is ClaimTiming { + // `block` scores zero, so the whole-turn fallback is excluded by the threshold itself. + return timing !== null && timing.confidence >= LIVE_TIMING_CONFIDENCE; +} + +/** + * Below this the match is not worth keeping at all and the claim falls back to its turn. + * + * Deliberately not zero: the best window always scores something, and a near-zero score means the + * extractor's paraphrase shares almost no vocabulary with the turn — at which point the whole turn + * is a more honest answer than a confidently wrong five seconds. + */ +const USABLE_TIMING_CONFIDENCE = 0.35; + +/** How many consecutive segments a claim may span. Whisper segments here run 1–3 seconds. */ +const MAX_WINDOW_SEGMENTS = 6; + +/** + * Words carrying no identifying signal, dropped before scoring. + * + * Skewed towards the vocabulary of argument rather than a general stopword list: "claim", "think" + * and "point" appear in most turns of most debates and match everything, which is the same problem + * "the" has. + * + * One rule governs what may *not* go in here: **a word whose opposite is scored must be scored + * too.** Drop one half of a polarity pair and the matcher can no longer tell the halves apart, so + * it will happily place a claim over the sentence that says the reverse — and this layer's whole + * job is pointing at the moment a particular thing was said. + * + * Three pairs were broken that way. `not` was dropped while `never`, `no`, `cannot`, `nothing`, + * `neither` and `without` were all kept, so "X is safe" and "X is not safe" scored identically. + * `more` and `most` were dropped while `less`, `fewer` and `least` were kept. `many` was dropped + * while `few` was kept. All four are now scored. + * + * Measured over the 852 matchable claims: 54 windows move, 35 of them on claims containing a + * negator, and the scores barely shift in aggregate — 131 rise, 147 fall, 574 unchanged, 13 claims + * newly clear the 0.40 bar and 7 newly fall below it. Cheap in score, and the 54 moves are the + * point: those are the claims the matcher was free to place against their own opposite. + * + * Modals are the deliberate exception. `can`, `could`, `may`, `might`, `must`, `should`, `will` + * and `would` are *all* dropped, so no pair is broken — a claim's modality is invisible to the + * matcher, which is symmetric and merely lossy rather than wrong. + */ +const STOPWORDS = new Set([ + 'about', + 'after', + 'again', + 'against', + 'all', + 'also', + 'and', + 'any', + 'are', + 'because', + 'been', + 'being', + 'both', + 'but', + 'can', + 'claim', + 'could', + 'did', + 'does', + 'doing', + 'done', + 'down', + 'each', + 'even', + 'every', + 'for', + 'from', + 'get', + 'going', + 'had', + 'has', + 'have', + 'her', + 'here', + 'him', + 'his', + 'how', + 'into', + 'its', + 'just', + 'know', + 'like', + 'look', + 'made', + 'make', + 'may', + 'mean', + 'might', + 'much', + 'must', + 'now', + 'off', + 'one', + 'only', + 'other', + 'our', + 'out', + 'over', + 'own', + 'point', + 'really', + 'said', + 'same', + 'say', + 'see', + 'she', + 'should', + 'some', + 'such', + 'than', + 'that', + 'the', + 'their', + 'them', + 'then', + 'there', + 'these', + 'they', + 'thing', + 'things', + 'think', + 'this', + 'those', + 'through', + 'too', + 'two', + 'under', + 'use', + 'very', + 'want', + 'was', + 'way', + 'well', + 'were', + 'what', + 'when', + 'where', + 'which', + 'while', + 'who', + 'why', + 'will', + 'with', + 'would', + 'yeah', + 'yes', + 'you', + 'your', +]); + +/** + * The negators English hides inside a verb, pulled back out as their own word. + * + * `wasn't` tokenises as one opaque word, so it is neither `was` nor `not` — which breaks the rule + * the {@link STOPWORDS} doc states: *a word whose opposite is scored must be scored*. The halves of + * the pair stop being comparable, and the matcher is free to place a claim over its own opposite. + * + * It is worse than a tie, because the miss costs twice. Scoring "vaccination was not safe": + * + * ``` + * "vaccination was safe" hits 2/3, precision 2/2 -> 0.6667 + * "vaccination wasn't safe" hits 2/3, precision 2/3 -> 0.6111 + * ``` + * + * The negated window not only fails to match the claim's `not`, it carries an extra content word + * that dilutes its precision — so the *affirmative* wins, and the card would quote the speaker + * saying the reverse of what they said. + * + * The corpus makes this one-directional rather than a coin toss. Measured over the 66 task files: + * **no claim contracts** — the extractor writes "does not", "did not" — while **248 of 7,633 + * segments do**, because people speak in contractions. 90 claims say "not" in a turn that contracts + * it. So the error only ever ran one way: towards the affirmative. + * + * Curly apostrophes are folded first for the same reason. `wasn’t` does not even survive + * tokenisation — `[a-z0-9']` splits it into `wasn` and `t` — so it would miss this expansion and + * contribute a junk content word instead. None appear in the corpus today; the two sources are a + * Whisper transcript and an LLM, and neither is under our control. + * + * `can't` and `won't` leave `ca` and `wo` behind, which are under the length floor and dropped. + * + * `cannot` is the same fault with no apostrophe to find it by, and it runs in both directions: + * a claim saying "cannot" scored 0.667 against "can be safe" and 0.611 against "can't be safe", + * while a claim saying "can't" scored 0.667 against "can be safe" and 0.611 against "cannot be + * safe". Whichever spelling each side picked, the affirmative won. It is folded to "can not". + * + * The apostrophe suffixes go for a related reason, one door further along. STOPWORDS holds `it`, + * `that`, `we`, `there`, `i` — but not `it's`, `that's`, `we're`, `there's`, `i'm`, because the + * apostrophe makes them a different token. They therefore survived as *content words*, and only on + * the speech side, since the extractor does not contract: `it's` 281 times in the corpus, `that's` + * 143, `i'm` 82, `there's` 76, `we're` 66. Each one is a distinct word in the window that no claim + * can ever match, so it cut that window's precision — the same one-directional penalty on speech + * that sounds like speech. Possessives fold the same way and want to: `person's` is `person`. + */ +function normalizeWordForms(text: string): string { + return ( + text + // Curly to straight, first, or nothing below matches. + .replace(/[’‘]/g, "'") + // The one negator with no apostrophe to find it by. + .replace(/\bcannot\b/g, 'can not') + .replace(/n't\b/g, ' not') + // Apostrophe suffixes carry no content: the stem is either the word that matters + // (`person's` -> `person`) or a stopword that should have been dropped (`it's` -> `it`). + .replace(/'(s|re|ve|ll|d|m)\b/g, '') + ); +} + +function words(text: string): string[] { + return normalizeWordForms(text.toLowerCase()).match(/[a-z0-9']+/g) ?? []; +} + +/** The words worth matching on: not stopwords, and long enough to mean something. */ +export function contentWords(text: string): string[] { + return words(text).filter(word => word.length > 2 && !STOPWORDS.has(word)); +} + +/** Whitespace- and punctuation-insensitive form, for comparing a block against its segments. */ +function normalize(text: string): string { + return words(text).join(' '); +} + +/** Segments on the debate timeline, earliest first. */ +function inTimeOrder(segments: DebateTranscriptSegment[]): DebateTranscriptSegment[] { + return [...segments].sort((a, b) => + a.start_ms === b.start_ms ? a.sequence_index - b.sequence_index : a.start_ms - b.start_ms + ); +} + +export type SegmentWindow = { startIndex: number; endIndex: number }; + +/** + * The run of consecutive segments whose text is this transcript block. + * + * Not a fuzzy match, and it does not need to be: publishing builds a block by joining consecutive + * same-speaker Whisper segments, so the block's text *is* that run, modulo the whitespace and + * punctuation `normalize` removes. On the test debate all six blocks matched exactly. + * + * The prefix fallback covers a block whose tail was edited or truncated after publishing — better + * to place the turn slightly short than to drop every claim in it. A block that matches nothing + * returns null and its claims end up untimed, which surfaces as "no moment known" rather than a + * guess. + */ +export function findBlockWindow(blockText: string, segments: DebateTranscriptSegment[]): SegmentWindow | null { + const target = normalize(blockText); + if (target.length === 0) return null; + + let bestPrefix: (SegmentWindow & { length: number }) | null = null; + + for (let start = 0; start < segments.length; start += 1) { + let joined = ''; + + for (let end = start; end < segments.length; end += 1) { + const next = normalize(segments[end].text); + joined = joined.length === 0 ? next : `${joined} ${next}`; + + // Past the target's length this run can only get further away. + if (joined.length > target.length) break; + if (joined === target) return { startIndex: start, endIndex: end }; + + if (target.startsWith(joined) && (bestPrefix === null || joined.length > bestPrefix.length)) { + bestPrefix = { startIndex: start, endIndex: end, length: joined.length }; + } + } + } + + // Only trust a partial block match once it accounts for most of the turn. + if (bestPrefix && bestPrefix.length >= target.length * 0.6) { + return { startIndex: bestPrefix.startIndex, endIndex: bestPrefix.endIndex }; + } + + return null; +} + +/** + * How well a window of transcript text accounts for a claim, in [0, 1]. + * + * Containment, not similarity: the claim is an extractor paraphrase, so it will not share a + * sentence shape with what was said, but its content words will nearly all appear in the seconds + * where it was said. Precision is mixed in at a quarter weight purely to break ties towards the + * tightest window — without it a six-segment window contains everything a one-segment window does + * and always wins, and the claim gets placed across the whole turn. + */ +export function scoreWindow(claimWords: string[], windowText: string): number { + if (claimWords.length === 0) return 0; + + const windowWords = new Set(contentWords(windowText)); + if (windowWords.size === 0) return 0; + + // Distinct words on both sides. Counting a repeated claim word once per occurrence let `hits` + // exceed the number of distinct words in the window, which pushed `precision` — a ratio of one + // set's size to another's — above 1 and the whole score past the [0, 1] this documents. A claim + // that says "AI" three times was scoring 1.05 and outranking a claim that said it once. + const distinct = new Set(claimWords); + const hits = [...distinct].reduce((count, word) => (windowWords.has(word) ? count + 1 : count), 0); + const containment = hits / distinct.size; + const precision = hits / windowWords.size; + + return containment * (0.75 + 0.25 * precision); +} + +/** The best-scoring window of consecutive segments within `[lo, hi]`, or null if there is none. */ +export function matchClaimWindow( + claimText: string, + segments: DebateTranscriptSegment[], + window: SegmentWindow +): (ClaimTiming & { source: 'segment' }) | null { + const claimWords = contentWords(claimText); + if (claimWords.length === 0) return null; + + let best: (ClaimTiming & { source: 'segment' }) | null = null; + + for (let start = window.startIndex; start <= window.endIndex; start += 1) { + let text = ''; + + for (let end = start; end <= Math.min(start + MAX_WINDOW_SEGMENTS - 1, window.endIndex); end += 1) { + text = text.length === 0 ? segments[end].text : `${text} ${segments[end].text}`; + const confidence = scoreWindow(claimWords, text); + + if (best === null || confidence > best.confidence) { + best = { + startMs: segments[start].start_ms, + endMs: segments[end].end_ms, + confidence, + source: 'segment', + }; + } + } + } + + return best; +} + +export type ResolveClaimTimingsInput = { + claims: TranscriptClaim[]; + blocks: TranscriptBlock[]; + /** The debate's Whisper transcript. Empty until it loads, or for a debate that has none. */ + segments: DebateTranscriptSegment[]; +}; + +/** + * Every claim's moment on the debate timeline, keyed by claim id. + * + * A claim with no published timecodes and no locatable block is absent from the map rather than + * given a zero, so callers can tell "at the start" from "unknown". + */ +export function resolveClaimTimings({ claims, blocks, segments }: ResolveClaimTimingsInput): Map { + const timings = new Map(); + const ordered = inTimeOrder(segments); + + // One block window per block, not per claim: locating a block is the expensive half, and a turn + // with five claims would otherwise pay for it five times. + const windows = new Map(); + const blocksById = new Map(blocks.map(block => [block.id, block])); + + const windowFor = (blockId: string): SegmentWindow | null => { + if (windows.has(blockId)) return windows.get(blockId) ?? null; + const block = blocksById.get(blockId); + const window = block && ordered.length > 0 ? findBlockWindow(block.text, ordered) : null; + windows.set(blockId, window); + return window; + }; + + for (const claim of claims) { + // Two turns, one deduped row: the block, the offsets and the relation entity on it all belong + // to whichever turn was read first, so any moment resolved here would be one of two and there + // is no way to tell which. Declining leaves the claim in the panel and out of the live layer, + // which is where a claim whose speaker cannot be established already goes. See `restated`. + if (claim.restated) continue; + + if (claim.publishedTiming) { + timings.set(claim.id, { ...claim.publishedTiming, confidence: 1, source: 'published' }); + continue; + } + + const window = windowFor(claim.blockId); + if (!window) continue; + + const matched = matchClaimWindow(claim.text, ordered, window); + if (matched && matched.confidence >= USABLE_TIMING_CONFIDENCE) { + timings.set(claim.id, matched); + continue; + } + + timings.set(claim.id, { + startMs: ordered[window.startIndex].start_ms, + endMs: ordered[window.endIndex].end_ms, + confidence: 0, + source: 'block', + }); + } + + return timings; +} + +/** + * Claims in the order they were actually said, which is not the order the graph returns them in. + * + * Relations are published with random positions (see `transcript-claims.ts`), so this is the only + * way to get chronological order. Claims with no known moment keep their incoming order and sit at + * the end, where a list that is mostly chronological is still useful. + */ +export function claimsInSpokenOrder(claims: TranscriptClaim[], timings: Map): TimedClaim[] { + return sortClaimsBySpokenOrder(claims, timings).map(claim => ({ ...claim, timing: timings.get(claim.id) ?? null })); +} + +/** A claim with no moment at all sorts after every claim that has one. */ +const momentOf = (timing: ClaimTiming | null) => timing?.startMs ?? Number.MAX_SAFE_INTEGER; + +/** + * Claims in the order they were said. + * + * Any usable match counts here, not only the firm ones {@link isAssertableMoment} admits: a list + * has to put every claim somewhere, and where it lands says far less than a printed timecode does. + * A match too loose even for that was already discarded by the resolver, which falls back to the + * claim's own turn — so a claim nothing could be recovered for still sorts into the turn it was + * made in, which is the answer the transcript block gives. + * + * All the claims of one turn therefore share a moment and tie. `tiebreak` decides those, since + * nothing about *when* can: the order they arrive in is relation `position`, which + * `debate-publish-draft.ts` fills with `Position.generate()` — random, not monotonic — so leaving + * ties to arrival order is leaving them to a shuffle. + */ +export function sortClaimsBySpokenOrder( + claims: T[], + timings: Map, + tiebreak: (a: T, b: T) => number = () => 0 +): T[] { + return claims + .map((claim, index) => ({ claim, index, moment: momentOf(timings.get(claim.id) ?? null) })) + .sort((a, b) => a.moment - b.moment || tiebreak(a.claim, b.claim) || a.index - b.index) + .map(entry => entry.claim); +} + +/** `2:05`, for a timecode chip. */ +export function formatTimecode(ms: number): string { + const total = Math.max(0, Math.round(ms / 1000)); + const minutes = Math.floor(total / 60); + const seconds = total % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} diff --git a/apps/web/core/debates/claims-best-order.ts b/apps/web/core/debates/claims-best-order.ts index 7ce3d2438b..a9e449e48c 100644 --- a/apps/web/core/debates/claims-best-order.ts +++ b/apps/web/core/debates/claims-best-order.ts @@ -127,10 +127,24 @@ export function useClaimsBestOrder( * Same shape as the debate feed's own sort: an absent rank sorts last rather than dropping the row. */ export function sortClaimsByBest(claims: T[], rankByClaimId: Map): T[] { - const rankOf = (claim: T) => rankByClaimId.get(ID.uuidToHex(claim.id)) ?? Number.MAX_SAFE_INTEGER; + const byBest = compareByBest(rankByClaimId); return claims .map((claim, index) => ({ claim, index })) - .sort((a, b) => rankOf(a.claim) - rankOf(b.claim) || a.index - b.index) + .sort((a, b) => byBest(a.claim, b.claim) || a.index - b.index) .map(entry => entry.claim); } + +/** + * The ranking as a bare comparator, for a sort that ranks *within* some other order. + * + * The claims panel orders by when a claim was said and needs this underneath: every claim of one + * turn shares that turn's moment, so they tie, and the ranking is a better answer to the tie than + * the shuffle that relation `position` supplies. Unranked claims sort last among their peers. + */ +export function compareByBest( + rankByClaimId: Map +): (a: T, b: T) => number { + const rankOf = (claim: T) => rankByClaimId.get(ID.uuidToHex(claim.id)) ?? Number.MAX_SAFE_INTEGER; + return (a, b) => rankOf(a) - rankOf(b); +} diff --git a/apps/web/core/debates/ontology.ts b/apps/web/core/debates/ontology.ts index 8edc800a49..cb4a0851b1 100644 --- a/apps/web/core/debates/ontology.ts +++ b/apps/web/core/debates/ontology.ts @@ -105,3 +105,29 @@ export const TEXT_BLOCK_TYPE_ID = SystemIds.TEXT_BLOCK; // 76474f2f… export const MARKDOWN_CONTENT_PROPERTY_ID = SystemIds.MARKDOWN_CONTENT; // e3e363d1… (matches spec) export const AUTHORS_PROPERTY_ID = '91a9e2f6e51a48f7997661de8561b690'; // ContentIds.AUTHORS_PROPERTY (matches spec) export const SOURCES_PROPERTY_ID = '49c5d5e1679a4dbdbfd33f618f227c94'; // ContentIds.SOURCES_PROPERTY (matches spec) + +/** + * When a claim was said, in milliseconds from the start of the debate timeline — the same origin + * the player's scrubber and `turn_durations_ms` use. + * + * These live on the *relation entity* of the transcript block → claim relation, not on the claim: + * one claim can be stated in two different turns, and each statement has its own moment. The + * relation entity is also typed `Selector` and pointed at `Debate videos`, which is the graph's + * existing shape for "this relation points at a span of its target" — the same one `Reply to` uses + * to anchor a comment to a range of text. + * + * Both properties already existed in the Geo ontology space, unused, declared as Integer. Nothing + * was minted for this. Note the API serialises Integer values as strings. + */ +export const CLAIM_START_OFFSET_PROPERTY_ID = 'a1d1cb557b184238ba0ec78ba7f289fb'; +export const CLAIM_END_OFFSET_PROPERTY_ID = '79a677b597f84ca8a1cf24eef7837b61'; + +/** + * The typing that says a relation points at a *span* of its target rather than the whole of it. + * + * A relation entity carrying offsets is typed `Selector` and given a `Target property` naming the + * property the span is measured in — `Debate videos` here, the same shape `Reply to` uses to anchor + * a comment to a range of text. Both existed in the Geo ontology space already. + */ +export const SELECTOR_TYPE_ID = '813ca865db9b486490dec6764febaab3'; +export const TARGET_PROPERTY_ID = 'e1788cdf9bae42e987b0d9791de09b31'; diff --git a/apps/web/core/debates/playback-utils.ts b/apps/web/core/debates/playback-utils.ts index fa12ba9148..44134bb577 100644 --- a/apps/web/core/debates/playback-utils.ts +++ b/apps/web/core/debates/playback-utils.ts @@ -8,6 +8,22 @@ export type TurnState = { seconds: number; } | null; +/** + * How close to the timeline's end counts as the end. + * + * Shared by `playbackEnded`, the background-playhead recovery and the scrubber markers, so the + * three cannot disagree about whether a debate has finished. It moved here from the playback hook + * once a third caller needed it: `claim-ticker.ts` is a pure module a Node script imports, and + * pulling a `'use client'` React hook in for one number was not an option. + * + * Getting that sharing wrong is what the markers did. A marker clamped its seek to `timelineMs - 1` + * on the reasoning that anything short of the duration is still playing — but a millisecond short + * is well inside this window, so the player called it ended and took the whole claim corner down, + * which is the blank the clamp was added to prevent. + */ +export const PLAYBACK_END_EPSILON_MS = 50; +export const PLAYBACK_END_EPSILON_SECONDS = PLAYBACK_END_EPSILON_MS / 1000; + /** * Has both per-slot recordings. A debate whose media job failed still passes this, so the feed * pairs it with {@link hasProcessedVideo} before rendering. diff --git a/apps/web/core/debates/transcript-claims.test.ts b/apps/web/core/debates/transcript-claims.test.ts index 04bf4dbfd5..8610079fca 100644 --- a/apps/web/core/debates/transcript-claims.test.ts +++ b/apps/web/core/debates/transcript-claims.test.ts @@ -17,12 +17,18 @@ type Claim = { position?: string; /** null models a claim the graph reports no space for. */ spaceId?: string | null; + /** + * Values on the block → claim relation entity, where timecodes live. Integers arrive from the + * API as strings, so these fixtures are written as strings too. + */ + offsets?: Array<{ propertyId: string; integer?: string | null } | null>; }; type Block = { id: string; position?: string; author?: string | null; + markdown?: string | null; claims: Claim[]; }; @@ -38,11 +44,13 @@ function response(blocks: Block[], transcriptPosition = 'a0'): DebateTranscriptC position: block.position ?? 'a0', toEntity: { id: block.id, + markdown: block.markdown === undefined ? [] : [{ spaceId: SPACE, text: block.markdown }], authors: block.author === null ? [] : [{ position: 'a0', toEntity: { id: block.author ?? PRESTON } }], claims: block.claims.map(claim => { const spaceId = claim.spaceId === undefined ? SPACE : claim.spaceId; return { position: claim.position ?? 'a0', + entity: claim.offsets === undefined ? null : { valuesList: claim.offsets }, toEntity: { id: claim.id, // Aggregated across spaces, so it is only the resolver's last resort. @@ -143,6 +151,30 @@ describe('groupTranscriptClaims', () => { expect(grouped.all).toHaveLength(1); }); + /** + * The row is deduped and carries one turn's block, offsets and relation entity out of two, so it + * cannot answer "when" or "who". The flag is what stops the surfaces that assert either from + * using the first turn's answer for both — see `restated`. + */ + it('marks a claim stated in two turns, so nothing reads one turn as both', () => { + const grouped = group( + response([ + { id: 'block-1', position: 'a1', author: PRESTON, claims: [{ id: 'claim-1' }] }, + { id: 'block-2', position: 'a2', author: ARTURAS, claims: [{ id: 'claim-1' }] }, + ]) + ); + + expect(grouped.all[0].restated).toBe(true); + }); + + it('does not mark a claim the graph merely returned twice for one turn', () => { + const grouped = group( + response([{ id: 'block-1', author: PRESTON, claims: [{ id: 'claim-1' }, { id: 'claim-1' }] }]) + ); + + expect(grouped.all[0].restated).toBe(false); + }); + it('still lists a claim once per speaker when that speaker repeats it', () => { const grouped = group( response([ @@ -447,3 +479,84 @@ describe('space scoping', () => { expect(claimsForParticipant(grouped, PRESTON)[0].spaceId).toBe(NAMING); }); }); + +describe('published timecodes', () => { + const START = 'a1d1cb557b184238ba0ec78ba7f289fb'; + const END = '79a677b597f84ca8a1cf24eef7837b61'; + + const offsets = (start: string | null, end: string | null) => [ + { propertyId: START, integer: start }, + { propertyId: END, integer: end }, + ]; + + it('reads the pair off the relation entity, parsing the strings the API sends', () => { + const { all } = group(response([{ id: 'block-1', claims: [{ id: 'c1', offsets: offsets('134600', '143140') }] }])); + + expect(all[0].publishedTiming).toEqual({ startMs: 134600, endMs: 143140 }); + }); + + it('reports no timing for the debates that predate timecodes', () => { + const { all } = group(response([{ id: 'block-1', claims: [{ id: 'c1' }] }])); + + expect(all[0].publishedTiming).toBeNull(); + }); + + // Each of these would otherwise be drawn on the timeline as a real moment. Falling back to + // matching against the transcript is both honest and, in practice, right. + it.each([ + ['only a start', offsets('1000', null)], + ['only an end', offsets(null, '2000')], + ['an unparseable value', offsets('about a minute in', '2000')], + // `Number('')` is 0, and 0 is a legal start — so a blank start beside a real end would have + // published "said in the first two seconds" as a certainty. + ['a blank start', offsets('', '2000')], + ['a whitespace start', offsets(' ', '2000')], + ['a blank end', offsets('1000', '')], + ['a negative start', offsets('-500', '2000')], + ['an end at the start', offsets('2000', '2000')], + ['an end before the start', offsets('4000', '2000')], + ])('discards a pair with %s', (_label, values) => { + const { all } = group(response([{ id: 'block-1', claims: [{ id: 'c1', offsets: values }] }])); + + expect(all[0].publishedTiming).toBeNull(); + }); + + it('carries the block each claim was said in, so its turn can be located on the recording', () => { + const { all, blocks } = group( + response([ + { id: 'block-1', markdown: 'The first turn, as spoken.', claims: [{ id: 'c1' }] }, + { id: 'block-2', markdown: 'The reply.', author: ARTURAS, claims: [{ id: 'c2' }] }, + ]) + ); + + expect(all.map(claim => claim.blockId)).toEqual(['block-1', 'block-2']); + expect(blocks).toEqual([ + { id: 'block-1', authorSpaceId: PRESTON, text: 'The first turn, as spoken.' }, + { id: 'block-2', authorSpaceId: ARTURAS, text: 'The reply.' }, + ]); + }); + + /** + * The same rule the claim loop already follows, on the turn list beside it. + * + * The graph can return one relation twice — duplicate publishes happen, which is why claims are + * deduped at all. The app never noticed, because every consumer keys blocks into a `Map`. The + * matching scripts do not: `export-claims-for-matching.ts` walks this list and filters claims by + * `blockId`, so a repeated block emits one turn twice with the same claims in each — and + * `build-plan-from-matches.ts` then reads that as a claim filed under two turns and rejects every + * claim in it. A silent loss of placements, from a duplicate this layer is meant to absorb. + */ + it('lists a turn once when the graph returns its relation twice', () => { + const { all, blocks } = group( + response([ + { id: 'block-1', markdown: 'The first turn, as spoken.', claims: [{ id: 'c1' }] }, + { id: 'block-1', markdown: 'The first turn, as spoken.', position: 'a1', claims: [{ id: 'c1' }] }, + ]) + ); + + expect(blocks).toEqual([{ id: 'block-1', authorSpaceId: PRESTON, text: 'The first turn, as spoken.' }]); + // And the repeat is still not a second turn for the claim — see `restated`. + expect(all).toHaveLength(1); + expect(all[0].restated).toBe(false); + }); +}); diff --git a/apps/web/core/debates/transcript-claims.ts b/apps/web/core/debates/transcript-claims.ts index a2ec55e778..97ddde12a2 100644 --- a/apps/web/core/debates/transcript-claims.ts +++ b/apps/web/core/debates/transcript-claims.ts @@ -1,6 +1,10 @@ import { Position } from '@geoprotocol/geo-sdk/lite'; -import { NAME_PROPERTY_ID } from '~/core/debates/ontology'; +import { + CLAIM_END_OFFSET_PROPERTY_ID, + CLAIM_START_OFFSET_PROPERTY_ID, + NAME_PROPERTY_ID, +} from '~/core/debates/ontology'; import { uuidToHex } from '~/core/id/normalize'; import { entityHomeSpaceId } from '~/core/utils/space/entity-home-space'; @@ -17,15 +21,71 @@ export type TranscriptClaim = { * claim unlinkable and unrespondable rather than pointing it at the wrong space. */ spaceId: string | null; + /** + * The transcript block this claim was first seen on — the turn it was said in. + * + * Carried so `claim-timing.ts` can place the claim inside that turn's slice of the recording + * rather than searching the whole debate, which is both slower and a way to match a phrase the + * other debater said. + */ + blockId: string; + /** + * The claim's timecodes as published on the block → claim relation entity, in milliseconds from + * the start of the debate timeline. Null for every debate published before timecodes existed, + * which is nearly all of them — the resolver recovers those from the transcript instead. + */ + publishedTiming: { startMs: number; endMs: number } | null; + /** + * The id of the block → claim relation's own entity, which is where {@link publishedTiming} is + * read from and where a backfill writes it. + * + * From the same relation as {@link blockId} — the turn the claim was first seen on — so the two + * always describe the same statement. Null only if the API omits it. + */ + relationEntityId: string | null; + /** + * True when the same claim entity is linked from more than one turn. + * + * `find-or-create` links an existing claim rather than minting a second, so one entity really can + * be two statements by two speakers — see the grouping tests. This row is deduped, though, and + * carries only the *first* relation's block, offsets and relation entity. Rather than let that + * silently stand in for both statements, the flag says the row cannot answer "when" or "who", and + * the surfaces that assert either decline it: {@link resolveClaimTimings} gives it no timing, so + * no card is drawn over a face and no timecode is printed beside a row, and the backfill scripts + * skip it rather than writing one occurrence and leaving the other unplaced. + * + * No restated claim has been observed in the published corpus yet, so the flag costs nothing in + * practice — but the grouping tests treat two speakers sharing a claim as the ordinary case under + * find-or-create, so it is a matter of when. Modelling timing per statement is the real fix and + * belongs with the backend work in GEO-2958. + */ + restated: boolean; +}; + +/** One turn of the debate as published, with the text needed to locate it on the recording. */ +export type TranscriptBlock = { + id: string; + /** The speaker's personal-space id, or null on a block with no `Authors` relation. */ + authorSpaceId: string | null; + /** The turn's verbatim text, in transcript order within the turn. */ + text: string; }; export type DebateTranscriptClaims = { - /** Every claim, deduped, in transcript order. */ + /** + * Every claim, deduped, in the order the graph returned them. + * + * Not chronological, despite how it reads: relations are published with `Position.generate()`, + * which is random rather than monotonic, so `position` order is arbitrary. `claim-timing.ts` + * recovers the real order from the recording. See {@link groupTranscriptClaims}. + */ all: TranscriptClaim[]; /** Claims keyed by the hex form of the speaker's `profile_space_id`. */ byAuthorSpaceId: Map; /** Claims on a block with no `Authors` relation. Empty for anything we publish. */ unattributed: TranscriptClaim[]; + /** The debate's turns, in the same arbitrary order, keyed by block id. */ + blocks: TranscriptBlock[]; totalCount: number; }; @@ -33,28 +93,97 @@ export const EMPTY_TRANSCRIPT_CLAIMS: DebateTranscriptClaims = { all: [], byAuthorSpaceId: new Map(), unattributed: [], + blocks: [], totalCount: 0, }; -type PresentRelation = { position?: string | null; toEntity: T }; +type PresentRelation = { + position?: string | null; + entityId?: string | null; + entity?: E; + toEntity: T; +}; /** - * Drop relations the API returned as null (or pointing at nothing) and put the rest in graph - * order. `relationsList` does not come back sorted by `position`, so ordering has to happen here - * for claims to read in transcript order. + * Drop relations the API returned as null (or pointing at nothing) and put the rest in `position` + * order. + * + * This is a stable order, not a chronological one. Every relation here was published with + * `Position.generate()`, which is random rather than monotonic, so sorting by it produces the same + * arbitrary sequence on every read rather than the order the turns were spoken in. That is enough + * for grouping and dedupe, which is all this function feeds; anything that needs real order takes + * it from `claim-timing.ts`. + * + * The relation's own `entity` and `entityId` are carried through, because on a block → claim + * relation that entity is where the claim's timecodes live and its id is where a backfill writes + * them. */ -function presentRelations( - relations: Array<{ position?: string | null; toEntity: T | null } | null> | null | undefined -): PresentRelation[] { - const present: PresentRelation[] = []; +function presentRelations( + relations: + | Array<{ position?: string | null; entityId?: string | null; entity?: E; toEntity: T | null } | null> + | null + | undefined +): PresentRelation[] { + const present: PresentRelation[] = []; for (const relation of relations ?? []) { - if (relation?.toEntity) present.push({ position: relation.position, toEntity: relation.toEntity }); + if (relation?.toEntity) { + present.push({ + position: relation.position, + entityId: relation.entityId, + entity: relation.entity, + toEntity: relation.toEntity, + }); + } } return present.sort((a, b) => Position.compare(a.position ?? null, b.position ?? null)); } +/** The turn's text as published in this space, or '' when the graph holds none. */ +function blockTextInSpace( + markdown: Array<{ spaceId: string; text?: string | null } | null> | null | undefined, + spaceId: string +): string { + for (const value of markdown ?? []) { + if (!value || typeof value.text !== 'string') continue; + if (uuidToHex(value.spaceId) === uuidToHex(spaceId)) return value.text.trim(); + } + return ''; +} + +/** + * The timecodes published on a block → claim relation entity, or null when it carries none. + * + * Integer values arrive as strings, so they are parsed rather than trusted. A pair that is + * incomplete, unparseable, negative, or not strictly increasing is discarded whole: a half-answer + * here would be drawn as a real moment on the timeline, and falling back to matching is both + * honest and usually right. + */ +function publishedTiming( + values: Array<{ propertyId: string; integer?: string | null } | null> | null | undefined +): { startMs: number; endMs: number } | null { + let startMs: number | null = null; + let endMs: number | null = null; + + for (const value of values ?? []) { + if (!value || value.integer === null || value.integer === undefined) continue; + // An empty or whitespace `integer` is a missing offset, not offset zero — `Number('')` is 0, + // and 0 is a legal `startMs`. A blank start beside a real end would publish "said in the first + // few seconds" as a certainty, scored 1.0, over a claim made anywhere in the debate. + if (value.integer.trim() === '') continue; + const parsed = Number(value.integer); + if (!Number.isFinite(parsed)) continue; + if (uuidToHex(value.propertyId) === uuidToHex(CLAIM_START_OFFSET_PROPERTY_ID)) startMs = parsed; + if (uuidToHex(value.propertyId) === uuidToHex(CLAIM_END_OFFSET_PROPERTY_ID)) endMs = parsed; + } + + if (startMs === null || endMs === null) return null; + if (startMs < 0 || endMs <= startMs) return null; + + return { startMs, endMs }; +} + type ClaimEntityNaming = { name?: string | null; spaceIds?: Array | null; @@ -116,9 +245,12 @@ export function groupTranscriptClaims(data: DebateTranscriptClaimsQuery, spaceId const all: TranscriptClaim[] = []; const byAuthorSpaceId = new Map(); const unattributed: TranscriptClaim[] = []; + const blocks: TranscriptBlock[] = []; const rowsByClaimId = new Map(); /** `${authorKey}:${claimKey}`; the empty author key stands for unattributed. */ const seenPairs = new Set(); + /** Turns already listed, so a relation the graph repeats does not become a second turn. */ + const seenBlockIds = new Set(); for (const transcript of presentRelations(data.entity?.transcripts)) { for (const block of presentRelations(transcript.toEntity.blocks)) { @@ -128,6 +260,20 @@ export function groupTranscriptClaims(data: DebateTranscriptClaimsQuery, spaceId // one turn's claims land in one group rather than being counted under each speaker. const authorSpaceId = presentRelations(blockEntity.authors)[0]?.toEntity.id; + // Deduped on the same grounds the claims below are: the graph can return one relation twice. + // Every reader in the app keys these into a `Map` and so never saw it, but the matching + // scripts walk this list — `export-claims-for-matching.ts` filters claims by `blockId`, so a + // repeated block emits one turn twice with the same claims in each, and the plan builder then + // reads that as a claim filed under two turns and drops every claim in it. + if (!seenBlockIds.has(blockEntity.id)) { + seenBlockIds.add(blockEntity.id); + blocks.push({ + id: blockEntity.id, + authorSpaceId: authorSpaceId ?? null, + text: blockTextInSpace(blockEntity.markdown, spaceId), + }); + } + for (const claim of presentRelations(blockEntity.claims)) { const claimEntity = claim.toEntity; const key = uuidToHex(claimEntity.id); @@ -137,9 +283,21 @@ export function groupTranscriptClaims(data: DebateTranscriptClaimsQuery, spaceId const resolved = resolveClaimNaming(claimEntity, spaceId); // A claim with no name has nothing to render — its text *is* its name. if (!resolved.text) continue; - row = { id: claimEntity.id, text: resolved.text, spaceId: resolved.spaceId }; + row = { + id: claimEntity.id, + text: resolved.text, + spaceId: resolved.spaceId, + blockId: blockEntity.id, + publishedTiming: publishedTiming(claim.entity?.valuesList), + relationEntityId: claim.entityId ?? null, + restated: false, + }; rowsByClaimId.set(key, row); all.push(row); + } else if (row.blockId !== blockEntity.id) { + // A second turn for a claim already seen. The same relation repeated inside one block is + // just noise and does not count — see `restated`. + row.restated = true; } const authorKey = authorSpaceId ? uuidToHex(authorSpaceId) : ''; @@ -159,7 +317,7 @@ export function groupTranscriptClaims(data: DebateTranscriptClaimsQuery, spaceId } } - return { all, byAuthorSpaceId, unattributed, totalCount: all.length }; + return { all, byAuthorSpaceId, unattributed, blocks, totalCount: all.length }; } /** diff --git a/apps/web/core/debates/use-claim-timings.test.ts b/apps/web/core/debates/use-claim-timings.test.ts new file mode 100644 index 0000000000..e6c6a08af4 --- /dev/null +++ b/apps/web/core/debates/use-claim-timings.test.ts @@ -0,0 +1,109 @@ +import { renderHook } from '@testing-library/react'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ClaimTiming } from './claim-timing'; +import type { DebateTranscriptClaims, TranscriptClaim } from './transcript-claims'; +import { useClaimTimings } from './use-claim-timings'; + +const transcript = vi.hoisted(() => ({ + calls: [] as { debateId: string; enabled: boolean }[], + state: { data: undefined as unknown, isSuccess: false, isError: false }, +})); + +vi.mock('./hooks', () => ({ + useDebateTranscript: (debateId: string, _format: string, enabled: boolean) => { + transcript.calls.push({ debateId, enabled }); + return transcript.state; + }, +})); + +const published = (startMs: number, endMs: number): ClaimTiming => ({ + startMs, + endMs, + confidence: 1, + source: 'published', +}); + +function claim(id: string, publishedTiming: ClaimTiming | null): TranscriptClaim { + return { + id, + text: `Claim ${id}`, + spaceId: 'space-1', + blockId: 'block-1', + relationEntityId: `relation-${id}`, + publishedTiming, + } as TranscriptClaim; +} + +function claims(all: TranscriptClaim[]): DebateTranscriptClaims { + return { + all, + byAuthorSpaceId: new Map(), + unattributed: [], + blocks: [{ id: 'block-1', text: 'Claim a and claim b', authorSpaceId: null }], + totalCount: all.length, + } as unknown as DebateTranscriptClaims; +} + +const lastCall = () => transcript.calls.at(-1); + +describe('useClaimTimings', () => { + beforeEach(() => { + transcript.calls = []; + transcript.state = { data: undefined, isSuccess: false, isError: false }; + }); + + /** + * The point of the whole hook once the backend publishes offsets natively (GEO-2958): there is + * nothing left for the transcript to tell us, so it is not asked for. + */ + it('does not fetch the transcript when every claim already carries its offsets', () => { + const { result } = renderHook(() => + useClaimTimings('debate-1', claims([claim('a', published(0, 1_000)), claim('b', published(2_000, 3_000))])) + ); + + expect(lastCall()?.enabled).toBe(false); + // And the published moments still resolve — the transcript was never the source for those. + expect(result.current.timings.get('a')).toMatchObject({ startMs: 0, endMs: 1_000, source: 'published' }); + expect(result.current.isReady).toBe(true); + }); + + it('fetches it when even one claim is unplaced', () => { + renderHook(() => useClaimTimings('debate-1', claims([claim('a', published(0, 1_000)), claim('b', null)]))); + + expect(lastCall()?.enabled).toBe(true); + }); + + it('asks for nothing with no claims, no debate, or while disabled', () => { + renderHook(() => useClaimTimings('debate-1', claims([]))); + expect(lastCall()?.enabled).toBe(false); + + renderHook(() => useClaimTimings(null, claims([claim('a', null)]))); + expect(lastCall()?.enabled).toBe(false); + + renderHook(() => useClaimTimings('debate-1', claims([claim('a', null)]), false)); + expect(lastCall()?.enabled).toBe(false); + }); + + /** + * A surface that reorders by time waits on `isReady`, so anything that leaves it false forever + * leaves that surface empty forever. A failed transcript is an answer: the published timings + * resolved, and the rest are not coming. + */ + it('is ready once a needed transcript fails, not only when it succeeds', () => { + transcript.state = { data: undefined, isSuccess: false, isError: false }; + const pending = renderHook(() => useClaimTimings('debate-1', claims([claim('a', null)]))); + expect(pending.result.current.isReady).toBe(false); + + transcript.state = { data: undefined, isSuccess: false, isError: true }; + const failed = renderHook(() => useClaimTimings('debate-1', claims([claim('a', null)]))); + expect(failed.result.current.isReady).toBe(true); + }); + + it('is ready immediately when no transcript is needed', () => { + const { result } = renderHook(() => useClaimTimings('debate-1', claims([claim('a', published(0, 1_000))]))); + + expect(result.current.isReady).toBe(true); + }); +}); diff --git a/apps/web/core/debates/use-claim-timings.ts b/apps/web/core/debates/use-claim-timings.ts new file mode 100644 index 0000000000..081c7886fa --- /dev/null +++ b/apps/web/core/debates/use-claim-timings.ts @@ -0,0 +1,59 @@ +'use client'; + +import * as React from 'react'; + +import { type ClaimTiming, resolveClaimTimings } from './claim-timing'; +import { useDebateTranscript } from './hooks'; +import type { DebateTranscriptClaims } from './transcript-claims'; + +export type ClaimTimingsResult = { + /** Claim id → when it was said. Absent for a claim whose moment could not be established. */ + timings: Map; + /** + * Whether every answer that is coming has arrived. + * + * False while the transcript is still loading, so a surface that reorders by time can hold + * rather than paint an order it is about to change under the reader — the same reason the claims + * panel holds its rows back while the ranking loads. + */ + isReady: boolean; +}; + +const EMPTY_TIMINGS = new Map(); + +/** + * When each of a debate's claims was said, on the debate timeline. + * + * Published timecodes come back with the claims themselves and need nothing else. Everything else + * is recovered by matching the claims against the Whisper transcript, which the player already + * fetches for subtitles — so on a debate card that is playing, this is free, and on one that is not + * it is a single extra request. + * + * Deliberately a hook over `DebateTranscriptClaims` rather than a wrapper around + * `useDebateTranscriptClaims`: the panel, the feed badge and the player each already hold the + * claims, and re-fetching them per surface is what the shared query key exists to avoid. + */ +export function useClaimTimings( + debateId: string | null, + claims: DebateTranscriptClaims, + enabled = true +): ClaimTimingsResult { + // Every claim already carries published timecodes, so the transcript would tell us nothing we do + // not already know. Nearly always false today and nearly always true later, which is the point. + const allPublished = claims.all.length > 0 && claims.all.every(claim => claim.publishedTiming !== null); + const needsTranscript = enabled && debateId !== null && claims.all.length > 0 && !allPublished; + + const transcript = useDebateTranscript(debateId ?? '', 'json', needsTranscript); + const segments = transcript.data?.segments; + + const timings = React.useMemo(() => { + if (claims.all.length === 0) return EMPTY_TIMINGS; + return resolveClaimTimings({ claims: claims.all, blocks: claims.blocks, segments: segments ?? [] }); + }, [claims.all, claims.blocks, segments]); + + // A failed transcript fetch is ready, not pending: published timings still resolved, and the rest + // are not coming. Reporting "still loading" forever would hold a time-ordered surface empty. + const isReady = !needsTranscript || transcript.isSuccess || transcript.isError; + + return { timings, isReady }; +} diff --git a/apps/web/core/debates/use-debate-playback.ts b/apps/web/core/debates/use-debate-playback.ts index 0f6fce2590..d6b9a29194 100644 --- a/apps/web/core/debates/use-debate-playback.ts +++ b/apps/web/core/debates/use-debate-playback.ts @@ -7,6 +7,7 @@ import { atom, useAtom } from 'jotai'; import type { Debate } from './api'; import { useDebateMedia, useDebateTranscript, useRecordingUrl } from './hooks'; import { + PLAYBACK_END_EPSILON_SECONDS, type PlayBothOutcome, type TurnState, clampSeconds, @@ -69,11 +70,6 @@ const MAX_BACKGROUND_RESTART_ATTEMPTS = 5; const STALL_AFTER_MS = 500; /** Progress smaller than this is float noise on `currentTime`, not playback. */ const STALL_EPSILON_SECONDS = 0.001; -/** - * How close to the timeline's end counts as the end. Shared by `playbackEnded` and the - * background recovery, so the two cannot disagree about whether a debate has finished. - */ -const PLAYBACK_END_EPSILON_SECONDS = 0.05; /** * Is this tab off screen? diff --git a/apps/web/core/io/debate-transcript-claims-document.ts b/apps/web/core/io/debate-transcript-claims-document.ts index 67948514ea..58190725f6 100644 --- a/apps/web/core/io/debate-transcript-claims-document.ts +++ b/apps/web/core/io/debate-transcript-claims-document.ts @@ -33,6 +33,8 @@ const DEBATE_TRANSCRIPT_CLAIMS_SOURCE = /* GraphQL */ ` $claimsPropertyId: UUID! $spaceId: UUID! $namePropertyId: UUID! + $markdownPropertyId: UUID! + $offsetPropertyIds: [UUID!] ) { entity(id: $id) { transcripts: relationsList(filter: { typeId: { is: $transcriptsPropertyId }, spaceId: { is: $spaceId } }) { @@ -43,6 +45,13 @@ const DEBATE_TRANSCRIPT_CLAIMS_SOURCE = /* GraphQL */ ` position toEntity { id + # The turn's text, per space. This is the verbatim concatenation of the speaker's + # Whisper segments, which is what lets claim-timing.ts locate the turn on the + # video timeline by matching it back against the transcript. + markdown: valuesList(filter: { propertyId: { is: $markdownPropertyId } }) { + spaceId + text + } authors: relationsList(filter: { typeId: { is: $authorsPropertyId }, spaceId: { is: $spaceId } }) { toEntity { id @@ -50,6 +59,20 @@ const DEBATE_TRANSCRIPT_CLAIMS_SOURCE = /* GraphQL */ ` } claims: relationsList(filter: { typeId: { is: $claimsPropertyId }, spaceId: { is: $spaceId } }) { position + # The id of the relation entity below, which is what a publisher writes timecodes + # onto. The app only reads them, so nothing here needs it — the backfill scripts do, + # and carrying it means they read this traversal rather than re-walking their own. + entityId + # The relation's own entity, which is where the claim's timecodes live — not on the + # claim, because one claim can be stated in two turns and each statement has its + # own moment. Empty for every debate published before timecodes existed, which is + # all of them bar the test debate; claim-timing.ts falls back to matching. + entity { + valuesList(filter: { propertyId: { in: $offsetPropertyIds }, spaceId: { is: $spaceId } }) { + propertyId + integer + } + } toEntity { id # Not space-scoped, so only a last resort — see the note above. @@ -76,6 +99,16 @@ const DEBATE_TRANSCRIPT_CLAIMS_SOURCE = /* GraphQL */ ` type RelationNode = { position?: string | null; toEntity: T | null } | null; +/** A block → claim relation, which carries the claim's timecodes on its own entity. */ +type ClaimRelationNode = { + position?: string | null; + /** The relation entity's id — where a claim's timecodes are published. */ + entityId?: string | null; + /** Integer values arrive as strings, the way the API serialises them. */ + entity?: { valuesList?: Array<{ propertyId: string; integer?: string | null } | null> | null } | null; + toEntity: ClaimEntity | null; +} | null; + type ClaimEntity = { id: string; name?: string | null; @@ -91,8 +124,9 @@ export type DebateTranscriptClaimsQuery = { blocks: Array< RelationNode<{ id: string; + markdown?: Array<{ spaceId: string; text?: string | null } | null> | null; authors: Array> | null; - claims: Array> | null; + claims: Array | null; }> > | null; }> @@ -108,6 +142,8 @@ type DebateTranscriptClaimsVariables = { claimsPropertyId: string; spaceId: string; namePropertyId: string; + markdownPropertyId: string; + offsetPropertyIds: string[]; }; export const debateTranscriptClaimsDocument = parse(DEBATE_TRANSCRIPT_CLAIMS_SOURCE) as TypedDocumentNode< diff --git a/apps/web/core/io/queries.ts b/apps/web/core/io/queries.ts index 5201fdf4ad..c609853ebf 100644 --- a/apps/web/core/io/queries.ts +++ b/apps/web/core/io/queries.ts @@ -6,10 +6,13 @@ import { COMMENT_REPLY_TO_ID, COMMENT_TYPE_ID } from '~/core/comment-ids'; import { AUTHORS_PROPERTY_ID, BLOCKS_PROPERTY_ID, + CLAIM_END_OFFSET_PROPERTY_ID, + CLAIM_START_OFFSET_PROPERTY_ID, DEBATE_CLAIMS_PROPERTY_ID, DEBATE_OPPOSED_BY_PROPERTY_ID, DEBATE_SUPPORTED_BY_PROPERTY_ID, DEBATE_TRANSCRIPTS_PROPERTY_ID, + MARKDOWN_CONTENT_PROPERTY_ID, NAME_PROPERTY_ID, VOTE_DEBATES_PROPERTY_ID, VOTE_TYPE_ID, @@ -720,6 +723,8 @@ export function getDebateTranscriptClaims(debateEntityId: string, spaceId: strin claimsPropertyId: DEBATE_CLAIMS_PROPERTY_ID, spaceId, namePropertyId: NAME_PROPERTY_ID, + markdownPropertyId: MARKDOWN_CONTENT_PROPERTY_ID, + offsetPropertyIds: [CLAIM_START_OFFSET_PROPERTY_ID, CLAIM_END_OFFSET_PROPERTY_ID], }, signal, }); diff --git a/apps/web/scripts/build-plan-from-matches.ts b/apps/web/scripts/build-plan-from-matches.ts new file mode 100644 index 0000000000..0512083477 --- /dev/null +++ b/apps/web/scripts/build-plan-from-matches.ts @@ -0,0 +1,325 @@ +/** + * Turns an LLM's segment choices into a publish plan, rejecting anything it cannot verify. + * + * The reader picks segments; this side owns the milliseconds. Every offset is read back out of the + * task file the reader was given, so a timecode is always a real boundary of the recording — a + * hallucinated time cannot get through, because times are never accepted as input. + * + * Everything else it checks is a way that has actually gone wrong somewhere: an index outside the + * turn, a range that ends before it starts, a claim answered twice, a claim invented, or a + * relation entity missing. A rejected claim is not a failure — it falls back to the matcher, which + * is the same place it would have been without any of this. + * + * One answers file per task file, same filename, holding the reader's choices. `startSegment` and + * `endSegment` are `i` values from that claim's own turn, inclusive; `notInTurn` is the escape + * hatch for a claim whose specifics are not in the turn it was filed under, which is the case the + * matcher cannot express and the main reason for reading at all: + * + * { "taskVersion": "3f2a19c04b7e", + * "matches": [ + * { "claimId": "ad46e41f…", "startSegment": 12, "endSegment": 15 }, + * { "claimId": "8401b74c…", "notInTurn": true } + * ] } + * + * `taskVersion` is copied from the top of the task file, and is the one thing here that is checked + * before anything else: an answer is a pair of segment indices, which are meaningful only against + * the transcript they were read from — see `taskVersion` in `lib/debate-claims.ts` for what that costs when they drift + * apart. + * + * Usage: + * bun scripts/build-plan-from-matches.ts --tasks ./claim-matching-tasks --answers ./answers --out plan.json + */ +import { readFile, readdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { + CLAIM_END_OFFSET_PROPERTY_ID, + CLAIM_START_OFFSET_PROPERTY_ID, + DEBATE_VIDEOS_PROPERTY_ID, + SELECTOR_TYPE_ID, + TARGET_PROPERTY_ID, + TYPES_PROPERTY_ID, +} from '../core/debates/ontology'; +import { API, arg } from './lib/debate-claims'; + +const TASKS = arg('tasks') ?? './claim-matching-tasks'; +const ANSWERS = arg('answers') ?? './claim-matching-answers'; +const OUT = arg('out') ?? 'claim-timecode-plan.json'; + +type TaskSegment = { i: number; startMs: number; endMs: number; text: string }; +type TaskClaim = { + claimId: string; + relationEntityId: string | null; + text: string; + published: { startMs: number; endMs: number; onSegmentBoundaries: boolean } | null; +}; +type Task = { + /** Absent on a task file written before this check existed — treated as a mismatch, not a pass. */ + taskVersion?: string; + debateEntityId: string; + debateName: string | null; + spaceId: string; + turns: { blockId: string; segments: TaskSegment[]; claims: TaskClaim[] }[]; +}; +type Answer = { claimId: string; startSegment?: number; endSegment?: number; notInTurn?: boolean }; +type AnswersFile = { taskVersion?: string; matches?: Answer[] }; + +const rejected: string[] = []; +const plans: { + debateEntityId: string; + debateName: string | null; + spaceId: string; + writes: { + entityId: string; + claimId: string; + claimText: string; + startMs: number; + endMs: number; + source: string; + /** Present when this replaces an offset already published — see the doc on overwriting. */ + corrects?: { startMs: number; endMs: number }; + }[]; +}[] = []; + +let placed = 0; +let declined = 0; +let unanswered = 0; +let confirmed = 0; +let corrected = 0; +/** A published claim the reader says is not in its turn — a wrong offset already live. */ +const disputed: string[] = []; +/** A hand-set span the reader cannot agree with in segments, only widen. Held back for a person. */ +const handSet: string[] = []; + +for (const file of (await readdir(TASKS)).filter(name => name.endsWith('.json'))) { + const task: Task = JSON.parse(await readFile(join(TASKS, file), 'utf8')); + + let answers: Answer[]; + let answeredVersion: string | undefined; + try { + const parsed: Answer[] | AnswersFile = JSON.parse(await readFile(join(ANSWERS, file), 'utf8')); + answers = Array.isArray(parsed) ? parsed : (parsed.matches ?? []); + answeredVersion = Array.isArray(parsed) ? undefined : parsed.taskVersion; + } catch (error) { + // A file that is not there is the ordinary case: the reader has not reached this debate yet, + // and its claims fall through to the matcher. A file that *is* there and will not parse is the + // reader's work sitting unreadable on disk — reporting that as "no answers file" files it under + // the benign case and throws the work away with nobody told. Same rule the fetch helpers now + // follow: only the expected absence is absence. + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw new Error(`answers for ${file} could not be read`, { cause: error }); + } + rejected.push(`${task.debateName}: no answers file`); + continue; + } + + /** + * The version gate, before any of this file's answers are looked at. + * + * Everything below checks an answer against the task file *as it is now*: the claim id exists, + * the indices are in the turn, the range runs forwards. A transcript re-cut between the read and + * this run passes all three and resolves the same indices to different milliseconds — so the + * script keeps its promise that a timecode is a real boundary of the recording, and publishes + * the wrong boundary. That is the one failure here that is invisible afterwards. + * + * The whole file goes rather than individual claims: the drift is a property of the transcript, + * so every answer read against the old one is equally suspect. They fall back to the matcher, + * which is where they would have been with no answers file at all. + * + * A task file from before this existed carries no version, so nothing can be verified against it + * and it fails the same way. Re-exporting stamps every file and takes a minute. + */ + if (task.taskVersion === undefined) { + rejected.push(`${task.debateName}: task file predates task versioning — re-export it`); + continue; + } + if (answeredVersion !== task.taskVersion) { + rejected.push( + `${task.debateName}: answers were read against ${answeredVersion ?? '(no version)'}, ` + + `this task file is ${task.taskVersion} — re-read it` + ); + continue; + } + + /** + * Built by hand rather than with `new Map(answers.map(…))`, which keeps the last entry for a + * repeated key and throws the rest away. A claim answered twice with two different spans would + * have been silently resolved to whichever came last — the opposite of what this script is for, + * and invisible in its own "rejected" count. + */ + const byClaimId = new Map(); + const answeredTwice = new Set(); + for (const answer of answers) { + if (byClaimId.has(answer.claimId)) answeredTwice.add(answer.claimId); + byClaimId.set(answer.claimId, answer); + } + + /** + * The same pre-scan on the *task* side: a claim listed in two of this file's turns. + * + * Checked against `writes` as they accumulated, which was too late to work. By the second + * occurrence the first had already been pushed — or had been counted as confirmed or declined, + * which pushes no write at all and so never tripped the check — so the file could report the + * claim rejected and plan it anyway. Neither occurrence is trustworthy: two turns disagree about + * where the claim was said, and nothing here can tell which is right, so both go. + * + * The export should not produce this — a claim carries one `blockId` — which is exactly why the + * guard has to be correct rather than plausible. Measured over the 66 task files on disk: none. + */ + const claimedTwice = new Set(); + const seenInTask = new Set(); + for (const turn of task.turns) { + for (const claim of turn.claims) { + if (seenInTask.has(claim.claimId)) claimedTwice.add(claim.claimId); + seenInTask.add(claim.claimId); + } + } + // Once per claim, not once per occurrence, so the count matches the number of claims dropped. + for (const claimId of claimedTwice) { + rejected.push(`${claimId}: appears in more than one turn of this task file`); + } + + const writes: (typeof plans)[number]['writes'] = []; + + for (const turn of task.turns) { + for (const claim of turn.claims) { + if (answeredTwice.has(claim.claimId)) { + rejected.push(`${claim.claimId}: answered more than once`); + continue; + } + // Already reported above, once, whichever occurrence this is. + if (claimedTwice.has(claim.claimId)) continue; + + const answer = byClaimId.get(claim.claimId); + if (!answer) { + unanswered += 1; + continue; + } + // The escape hatch, and the reason this is worth doing at all: the matcher cannot decline. + // A claim whose specifics live in another turn has no right answer here, and saying so is + // the right answer. + if (answer.notInTurn) { + // On an unpublished claim this is just a decline. On a published one it says a live offset + // is wrong, which needs removing rather than rewriting — a different op and a judgement + // call, so it is reported rather than acted on. + if (claim.published) + disputed.push(`${claim.claimId}: published offset disputed — "${claim.text.slice(0, 60)}"`); + else declined += 1; + continue; + } + + const start = turn.segments.find(segment => segment.i === answer.startSegment); + const end = turn.segments.find(segment => segment.i === answer.endSegment); + if (!start || !end) { + rejected.push(`${claim.claimId}: segment ${answer.startSegment}–${answer.endSegment} is not in this turn`); + continue; + } + if (end.endMs <= start.startMs) { + rejected.push(`${claim.claimId}: range ends before it starts`); + continue; + } + if (!claim.relationEntityId) { + rejected.push(`${claim.claimId}: no relation entity to write to`); + continue; + } + // A published claim the reader agrees with needs no write. Republishing the same numbers + // would spend a transaction to change nothing. + if (claim.published && claim.published.startMs === start.startMs && claim.published.endMs === end.endMs) { + confirmed += 1; + continue; + } + + // A span someone set by hand to a boundary inside a segment cannot be agreed with in segments, + // only widened outward to the segment edges — so a differing answer here does not distinguish + // "the reader disagrees" from "the reader agrees and lost precision". Both would overwrite a + // human's work, so neither is done automatically. + if (claim.published && !claim.published.onSegmentBoundaries) { + handSet.push( + `${claim.claimId}: hand-set ${claim.published.startMs}–${claim.published.endMs}ms, read as ` + + `${start.startMs}–${end.endMs}ms` + ); + continue; + } + + writes.push({ + entityId: claim.relationEntityId, + claimId: claim.claimId, + claimText: claim.text, + startMs: start.startMs, + endMs: end.endMs, + source: 'llm-read', + ...(claim.published ? { corrects: claim.published } : null), + }); + if (claim.published) corrected += 1; + else placed += 1; + } + } + + const known = new Set(task.turns.flatMap(turn => turn.claims.map(claim => claim.claimId))); + for (const answer of answers) { + if (!known.has(answer.claimId)) rejected.push(`${answer.claimId}: not a claim in this debate`); + } + + if (writes.length > 0) { + plans.push({ + debateEntityId: task.debateEntityId, + debateName: task.debateName, + spaceId: task.spaceId, + writes, + }); + } +} + +console.log(`placed by reading: ${placed}`); +console.log(`published offsets confirmed as correct: ${confirmed}`); +console.log(`published offsets corrected: ${corrected}`); +console.log(`declined as not in the turn: ${declined}`); +console.log(`left unanswered: ${unanswered}`); +console.log(`rejected: ${rejected.length}`); +if (handSet.length > 0) { + console.log(`\nhand-set offsets the reader answered differently — decide these by hand: ${handSet.length}`); + for (const line of handSet) console.log(` ? ${line}`); +} +if (disputed.length > 0) { + console.log(`\npublished offsets the reader says are wrong — decide these by hand: ${disputed.length}`); + for (const line of disputed) console.log(` ? ${line}`); +} +for (const reason of rejected.slice(0, 20)) console.log(` ! ${reason}`); + +const bySpace = new Map(); +for (const plan of plans) bySpace.set(plan.spaceId, (bySpace.get(plan.spaceId) ?? 0) + plan.writes.length); +console.log('\nwrites per space (one proposal each):'); +for (const [spaceId, count] of [...bySpace].sort((a, b) => b[1] - a[1])) console.log(` ${spaceId} ${count}`); + +await writeFile( + OUT, + `${JSON.stringify( + { + generatedAt: new Date().toISOString(), + api: API, + source: 'llm-read', + constants: { + TYPES_PROPERTY: TYPES_PROPERTY_ID, + SELECTOR_TYPE: SELECTOR_TYPE_ID, + TARGET_PROPERTY: TARGET_PROPERTY_ID, + DEBATE_VIDEOS_PROPERTY: DEBATE_VIDEOS_PROPERTY_ID, + }, + offsetProperties: { start: CLAIM_START_OFFSET_PROPERTY_ID, end: CLAIM_END_OFFSET_PROPERTY_ID }, + totals: { + placed, + confirmed, + corrected, + declined, + unanswered, + rejected: rejected.length, + disputed: disputed.length, + handSet: handSet.length, + }, + debates: plans, + }, + null, + 2 + )}\n` +); +const writeCount = plans.reduce((total, plan) => total + plan.writes.length, 0); +console.log(`\nwrote ${OUT} — ${writeCount} writes across ${plans.length} debates`); diff --git a/apps/web/scripts/calibrate-match-score.ts b/apps/web/scripts/calibrate-match-score.ts new file mode 100644 index 0000000000..954d688a6a --- /dev/null +++ b/apps/web/scripts/calibrate-match-score.ts @@ -0,0 +1,70 @@ +/** + * How wrong is the matcher, at each confidence? + * + * The one debate with hand-checked published offsets is a ground-truth set: run the matcher on its + * claims while *ignoring* what was published, and the difference between the two is the matcher's + * real error. That is the only honest basis for choosing `LIVE_TIMING_CONFIDENCE` — the current + * 0.55 was eyeballed against the same debate without measuring the error either side of it. + * + * The card now appears at the claim's end, so the end offset is the number that matters. + * + * Usage: bun scripts/calibrate-match-score.ts [debateEntityId] [spaceId] + */ +import { findBlockWindow, matchClaimWindow } from '../core/debates/claim-timing'; +import { fetchDebateClaims, fetchTranscriptSegments } from './lib/debate-claims'; + +const DEBATE = (process.argv[2] ?? '01a0a60772dc7cb09bf6ebba15e97b67').replaceAll('-', ''); +const SPACE = process.argv[3] ?? '4582fbbee28a16589154f7e36f1ee3c5'; + +const claims = await fetchDebateClaims(DEBATE, SPACE); +const segments = await fetchTranscriptSegments(DEBATE); +const ordered = [...segments].sort((a, b) => + a.start_ms === b.start_ms ? a.sequence_index - b.sequence_index : a.start_ms - b.start_ms +); +const blocksById = new Map(claims.blocks.map(block => [block.id, block])); + +type Row = { confidence: number; endErrorMs: number; startErrorMs: number; text: string }; +const rows: Row[] = []; + +for (const claim of claims.all) { + if (!claim.publishedTiming) continue; + const block = blocksById.get(claim.blockId); + if (!block) continue; + const window = findBlockWindow(block.text, ordered); + if (!window) continue; + const matched = matchClaimWindow(claim.text, ordered, window); + if (!matched) continue; + + rows.push({ + confidence: matched.confidence, + endErrorMs: Math.abs(matched.endMs - claim.publishedTiming.endMs), + startErrorMs: Math.abs(matched.startMs - claim.publishedTiming.startMs), + text: claim.text, + }); +} + +rows.sort((a, b) => b.confidence - a.confidence); + +console.log(`ground-truth claims: ${rows.length}\n`); +console.log(' score end err start err claim'); +for (const row of rows) { + console.log( + ` ${row.confidence.toFixed(2)} ${(row.endErrorMs / 1000).toFixed(1).padStart(6)}s ` + + `${(row.startErrorMs / 1000).toFixed(1).padStart(6)}s ${row.text.slice(0, 50)}` + ); +} + +console.log('\nif the bar were set here, what would surface and how wrong would it be:'); +console.log(' bar claims worst end err median end err'); +for (const bar of [0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.7]) { + const kept = rows.filter(row => row.confidence >= bar); + if (kept.length === 0) { + console.log(` ${bar.toFixed(2)} 0 — —`); + continue; + } + const errors = kept.map(row => row.endErrorMs).sort((a, b) => a - b); + const median = errors[Math.floor(errors.length / 2)]; + console.log( + ` ${bar.toFixed(2)} ${String(kept.length).padStart(6)} ${(errors.at(-1)! / 1000).toFixed(1).padStart(11)}s ${(median / 1000).toFixed(1).padStart(12)}s` + ); +} diff --git a/apps/web/scripts/dump-match-windows.ts b/apps/web/scripts/dump-match-windows.ts new file mode 100644 index 0000000000..5507dda28a --- /dev/null +++ b/apps/web/scripts/dump-match-windows.ts @@ -0,0 +1,82 @@ +/** + * Dumps each claim, its match score, and the transcript it was matched to — so a human (or an LLM + * reading the output) can judge whether the match is right and calibrate the confidence bar on more + * than the one hand-checked debate. + * + * Prints the whole turn as well as the matched window, because the question is not "does the window + * look plausible" but "is this where the claim was actually said, out of everything in this turn". + * + * Usage: bun scripts/dump-match-windows.ts [howManyDebates] [skip] + */ +import { contentWords, findBlockWindow, matchClaimWindow } from '../core/debates/claim-timing'; +import { fetchAllDebates, fetchDebateClaims, fetchTranscriptSegments, numberAt } from './lib/debate-claims'; + +const want = numberAt(2, 'howManyDebates', 5, 1); +const skip = numberAt(3, 'skip', 0); + +const debates = await fetchAllDebates(); +let seen = 0; +let used = 0; + +for (const debate of debates) { + if (used >= want) break; + + for (const spaceId of debate.spaceIds ?? []) { + const claims = await fetchDebateClaims(debate.id, spaceId); + if (claims.all.length === 0) continue; + + seen += 1; + if (seen <= skip) break; + + const segments = await fetchTranscriptSegments(debate.id); + if (segments.length === 0) break; + + const ordered = [...segments].sort((a, b) => + a.start_ms === b.start_ms ? a.sequence_index - b.sequence_index : a.start_ms - b.start_ms + ); + + used += 1; + console.log(`\n\n${'='.repeat(100)}`); + console.log(`DEBATE ${used}: ${debate.name}`); + console.log('='.repeat(100)); + + for (const block of claims.blocks) { + const inBlock = claims.all.filter(claim => claim.blockId === block.id); + if (inBlock.length === 0) continue; + + const window = findBlockWindow(block.text, ordered); + console.log(`\n--- TURN (${inBlock.length} claim${inBlock.length === 1 ? '' : 's'}) ---`); + console.log(`TRANSCRIPT: ${block.text}`); + + for (const claim of inBlock) { + const matched = window ? matchClaimWindow(claim.text, ordered, window) : null; + console.log(`\n CLAIM: ${claim.text}`); + if (!matched) { + console.log(' SCORE: — (no window; falls back to the whole turn)'); + continue; + } + const text = ordered + .filter(s => s.start_ms >= matched.startMs && s.end_ms <= matched.endMs) + .map(s => s.text) + .join(' '); + // How much of the claim actually landed, and how much of it exists in the turn at all. + const claimWords = new Set(contentWords(claim.text)); + const windowWords = new Set(contentWords(text)); + const turnWords = new Set(contentWords(block.text)); + const inWindow = [...claimWords].filter(w => windowWords.has(w)).length; + const inTurn = [...claimWords].filter(w => turnWords.has(w)).length; + console.log( + ` SCORE: ${matched.confidence.toFixed(2)} words: ${inWindow}/${claimWords.size} in window, ` + + `${inTurn}/${claimWords.size} anywhere in the turn` + ); + console.log(` MATCHED: "${text}"`); + if (claim.publishedTiming) { + const same = + claim.publishedTiming.startMs === matched.startMs && claim.publishedTiming.endMs === matched.endMs; + console.log(` PUBLISHED: ${same ? 'same span' : 'DIFFERENT span (a human corrected this)'}`); + } + } + } + break; + } +} diff --git a/apps/web/scripts/export-claims-for-matching.ts b/apps/web/scripts/export-claims-for-matching.ts new file mode 100644 index 0000000000..3594b2385a --- /dev/null +++ b/apps/web/scripts/export-claims-for-matching.ts @@ -0,0 +1,207 @@ +/** + * Exports every unplaced claim with the transcript of the turn it was made in, as one task file per + * debate, for an LLM to read and place. + * + * The matcher scores word overlap, which tracks how closely the extractor echoed the speech rather + * than whether the match is right — measured over 59 claims, it is wrong about 1 in 22 even above + * its bar, and it cannot tell "the window is misplaced" from "this turn does not contain the claim". + * Reading the turn answers both. This produces the reading material. + * + * Claims are offered as *segment indices*, never as milliseconds: the reader picks a first and last + * segment and this side turns those into offsets, so a timecode can only ever land on a real + * boundary of the recording and cannot be invented. + * + * Read-only. Writes task files; publishes nothing. + * + * Each file carries a `taskVersion` fingerprint of what the reader reads. The answers file has to + * echo it, so answers written against an earlier cut of a transcript cannot be planned from — see + * {@link taskVersion}. + * + * Usage: + * bun scripts/export-claims-for-matching.ts --out ./claim-matching-tasks [--limit N] + */ +import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { DebateTranscriptSegment } from '../core/debates/api'; +import { findBlockWindow, matchClaimWindow } from '../core/debates/claim-timing'; +import { + arg, + fetchAllDebates, + fetchDebateClaims, + fetchTranscriptSegments, + numberArg, + taskVersion, +} from './lib/debate-claims'; + +const OUT = arg('out') ?? './claim-matching-tasks'; +const LIMIT = numberArg('limit', { fallback: Infinity, min: 1 }); +/** + * Claims that already carry offsets come out too, to be confirmed rather than placed. + * + * Most of what is published was written by `plan-claim-timecodes.ts` from the matcher alone, at a + * 0.7 score, with nothing human or otherwise reading the transcript behind it — and a published + * offset is the app's definition of certainty, scored 1.0, so an error in one is invisible from + * then on. Reading them back is the only chance to catch one, and the run prints how many there + * are rather than this comment guessing at a number that changes with every backfill. + */ +const SKIP_PUBLISHED = process.argv.includes('--skip-published'); + +await mkdir(OUT, { recursive: true }); + +/** + * Task files from an earlier run, cleared before this one writes. + * + * `build-plan-from-matches.ts` reads *every* JSON file in this directory, so anything left behind + * is still planning writes. A second run with a lower `--limit`, or one after some of the corpus + * was published, leaves files for debates this run deliberately did not export — and the plan then + * quietly contains them, built from whatever the graph looked like the first time. + * + * Only files this script recognises as its own output go: read, parsed, and required to carry a + * `debateEntityId` and `turns`. Anything else in the directory is somebody's, and an `--out` that + * happens to point at a directory holding the reader's answers must not eat them. + */ +const stale: string[] = []; +for (const name of (await readdir(OUT)).filter(name => name.endsWith('.json'))) { + try { + const parsed = JSON.parse(await readFile(join(OUT, name), 'utf8')); + if (typeof parsed?.debateEntityId === 'string' && Array.isArray(parsed?.turns)) stale.push(name); + } catch { + // Unreadable, so not recognisably ours. Left alone rather than guessed at. + } +} +for (const name of stale) await rm(join(OUT, name)); +if (stale.length > 0) console.log(`cleared ${stale.length} task files from an earlier run`); + +const inTimeOrder = (segments: DebateTranscriptSegment[]) => + [...segments].sort((a, b) => + a.start_ms === b.start_ms ? a.sequence_index - b.sequence_index : a.start_ms - b.start_ms + ); + +const debates = await fetchAllDebates(); +let written = 0; +let claimsExported = 0; +let alreadyPublished = 0; +let noTranscript = 0; +/** + * Spaces whose claims could not be read at all. + * + * Distinct from a space with no claims in it, which is the ordinary case this loop walks past. A + * failed request used to be swallowed into that same `continue`, so a GraphQL error or an outage + * dropped the debate from the task files and the run still announced how many it had written — the + * export's contract is "every unplaced claim is in here", and a partial set that looks whole is how + * a claim silently never gets placed. Mirrors `plan-claim-timecodes.ts`, which counts the same + * thing on the same call, and `fetchTranscriptSegments`, which stopped reporting failures as + * absence for the same reason. + */ +const failedSpaces: string[] = []; + +for (const debate of debates) { + if (written >= LIMIT) break; + + for (const spaceId of debate.spaceIds ?? []) { + let claims; + try { + claims = await fetchDebateClaims(debate.id, spaceId); + } catch (error) { + console.error(` ! ${debate.id} in ${spaceId}: ${String(error).slice(0, 120)}`); + failedSpaces.push(`${debate.id} in ${spaceId}`); + continue; + } + if (claims.all.length === 0) continue; + + const segments = inTimeOrder(await fetchTranscriptSegments(debate.id)); + if (segments.length === 0) { + noTranscript += 1; + break; + } + + const turns = []; + for (const block of claims.blocks) { + // `restated` claims are left out: the row carries one turn's relation entity out of two, so + // an answer for it could only ever be published against one of the statements. See `restated`. + const inTurn = claims.all.filter(claim => claim.blockId === block.id && !claim.restated); + const pending = inTurn.filter(claim => claim.publishedTiming === null || !SKIP_PUBLISHED); + alreadyPublished += inTurn.filter(claim => claim.publishedTiming !== null).length; + if (pending.length === 0) continue; + + // Where the turn sits on the recording. A turn that cannot be located is offered whole — + // the reader can still find the claim, which is more than the matcher can do there. + const window = findBlockWindow(block.text, segments); + const slice = window ? segments.slice(window.startIndex, window.endIndex + 1) : segments; + + turns.push({ + blockId: block.id, + turnLocated: window !== null, + segments: slice.map((segment, index) => ({ + i: index, + startMs: segment.start_ms, + endMs: segment.end_ms, + text: segment.text, + })), + claims: pending.map(claim => { + const guess = window ? matchClaimWindow(claim.text, segments, window) : null; + const guessStart = guess ? slice.findIndex(s => s.start_ms === guess.startMs) : -1; + const guessEnd = guess ? slice.findIndex(s => s.end_ms === guess.endMs) : -1; + // A published span, shown in the same units the answer is given in so it can be agreed + // with or corrected rather than re-derived. Offsets that do not sit on segment boundaries + // are reported as milliseconds and flagged, which is itself worth knowing. + const publishedStart = claim.publishedTiming + ? slice.findIndex(s => s.start_ms === claim.publishedTiming!.startMs) + : -1; + const publishedEnd = claim.publishedTiming + ? slice.findIndex(s => s.end_ms === claim.publishedTiming!.endMs) + : -1; + + return { + claimId: claim.id, + relationEntityId: claim.relationEntityId, + text: claim.text, + /** Present means confirm or correct it, not place it. */ + published: claim.publishedTiming + ? { + startMs: claim.publishedTiming.startMs, + endMs: claim.publishedTiming.endMs, + startSegment: publishedStart === -1 ? null : publishedStart, + endSegment: publishedEnd === -1 ? null : publishedEnd, + onSegmentBoundaries: publishedStart !== -1 && publishedEnd !== -1, + } + : null, + // The matcher's answer, to check rather than to trust. It is right far more often than + // not, so confirming is quick — but it is the thing being audited, so a low score is a + // reason to look harder, never a reason to accept. + matcherGuess: + guess && guessStart !== -1 && guessEnd !== -1 + ? { startSegment: guessStart, endSegment: guessEnd, score: Number(guess.confidence.toFixed(2)) } + : null, + }; + }), + }); + claimsExported += pending.length; + } + + if (turns.length === 0) break; + + written += 1; + // Stamped so the reader can echo it — see {@link taskVersion}. Written first in the file so it + // is the first thing read, by a person or by a model, rather than buried under the transcript. + const task = { debateEntityId: debate.id, debateName: debate.name, spaceId, turns }; + await writeFile( + join(OUT, `${debate.id}.json`), + `${JSON.stringify({ taskVersion: taskVersion(task), ...task }, null, 2)}\n` + ); + break; + } +} + +console.log(`wrote ${written} task files to ${OUT}`); +console.log(` claims in the task files: ${claimsExported}`); +console.log( + ` of which already carry offsets: ${SKIP_PUBLISHED ? `0 (${alreadyPublished} skipped)` : alreadyPublished} — confirm or correct these` +); +if (noTranscript > 0) console.log(` debates with no transcript to read: ${noTranscript}`); +if (failedSpaces.length > 0) { + console.error(` spaces that failed to load: ${failedSpaces.length} — these task files are missing`); + for (const where of failedSpaces.slice(0, 10)) console.error(` ! ${where}`); + process.exitCode = 1; +} diff --git a/apps/web/scripts/lib/debate-claims.test.ts b/apps/web/scripts/lib/debate-claims.test.ts new file mode 100644 index 0000000000..8c88d9dc3c --- /dev/null +++ b/apps/web/scripts/lib/debate-claims.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; + +import { type VersionableTask, taskVersion } from './debate-claims'; + +/** + * The gate between a reader's answers and the graph. + * + * An answer is a pair of segment indices and nothing else — no milliseconds, by design, so a + * timecode can only ever be a real boundary of the recording. The cost of that design is that an + * answer means nothing away from the task file it was read against: re-cut the transcript and the + * same indices still resolve, to different words. This is what catches that, so what it does and + * does not notice is the whole of the guarantee. + */ +describe('taskVersion', () => { + /** Wider than `VersionableTask` so the fixtures can carry the fields the real task file does. */ + type Task = VersionableTask & { + debateName?: string | null; + turns: { + blockId: string; + segments: { i: number; startMs: number; endMs: number; text: string }[]; + claims: { claimId: string; text: string; published?: unknown; matcherGuess?: unknown }[]; + }[]; + }; + + const task = (overrides: Partial = {}): Task => ({ + debateEntityId: 'debate-1', + debateName: 'Getting married', + turns: [ + { + blockId: 'block-1', + segments: [ + { i: 0, startMs: 0, endMs: 1_000, text: 'marriage is a contract' }, + { i: 1, startMs: 1_000, endMs: 2_400, text: 'and contracts can be broken' }, + ], + claims: [{ claimId: 'claim-1', text: 'Marriage is a contract.', published: null, matcherGuess: null }], + }, + ], + ...overrides, + }); + + it('gives the same task the same version', () => { + expect(taskVersion(task())).toBe(taskVersion(task())); + }); + + it('is short enough to copy into an answers file by hand', () => { + expect(taskVersion(task())).toMatch(/^[0-9a-f]{12}$/); + }); + + /** + * The failure this exists for. A re-transcription moves the boundaries under the same indices, so + * every check in `build-plan-from-matches.ts` still passes and the published offset is wrong. + */ + it('changes when a segment boundary moves', () => { + const recut = task(); + recut.turns[0].segments[1] = { i: 1, startMs: 1_200, endMs: 2_600, text: 'and contracts can be broken' }; + + expect(taskVersion(recut)).not.toBe(taskVersion(task())); + }); + + // The reader matched the claim's wording, so a claim reworded between export and read is a claim + // whose answer was about a different sentence. + it('changes when a claim is reworded', () => { + const reworded = task(); + reworded.turns[0].claims[0].text = 'Marriage is a legal contract.'; + + expect(taskVersion(reworded)).not.toBe(taskVersion(task())); + }); + + it('changes when a claim is filed under a different turn', () => { + const refiled = task(); + refiled.turns[0].blockId = 'block-2'; + + expect(taskVersion(refiled)).not.toBe(taskVersion(task())); + }); + + it('changes when a claim joins the turn', () => { + const grown = task(); + grown.turns[0].claims.push({ claimId: 'claim-2', text: 'Contracts can be broken.' }); + + expect(taskVersion(grown)).not.toBe(taskVersion(task())); + }); + + /** + * Deliberately blind to these two. + * + * `published` and `matcherGuess` are re-read from the current task file when the plan is built, + * so a backfill landing between the read and the run leaves the reader's segment choice exactly + * as good as it was. Versioning them would throw away correct work every time anything published + * — which is the common case, since this pipeline is what publishes. + */ + it('ignores what has been published since, and what the matcher guessed', () => { + const republished = task(); + republished.turns[0].claims[0].published = { startMs: 0, endMs: 2_400, onSegmentBoundaries: true }; + republished.turns[0].claims[0].matcherGuess = { startSegment: 0, endSegment: 1, score: 0.81 }; + + expect(taskVersion(republished)).toBe(taskVersion(task())); + }); + + // Segment *text* is not versioned either: the indices and their times are what an answer resolves + // through, and a re-punctuated segment at the same boundaries is the same moment of the recording. + it('ignores a segment respelled at the same boundaries', () => { + const respelled = task(); + respelled.turns[0].segments[0].text = 'Marriage is a contract,'; + + expect(taskVersion(respelled)).toBe(taskVersion(task())); + }); + + it('tells two debates apart', () => { + expect(taskVersion(task({ debateEntityId: 'debate-2' }))).not.toBe(taskVersion(task())); + }); +}); diff --git a/apps/web/scripts/lib/debate-claims.ts b/apps/web/scripts/lib/debate-claims.ts new file mode 100644 index 0000000000..7f3fcb6a50 --- /dev/null +++ b/apps/web/scripts/lib/debate-claims.ts @@ -0,0 +1,257 @@ +/** + * Reading a debate's claims and transcript from testnet, for the scripts that work on timecodes. + * + * Both `verify-claim-timing.ts` and `plan-claim-timecodes.ts` need exactly this, and each had its + * own copy of the traversal — the same forty lines of GraphQL the app already carries in + * `core/io/debate-transcript-claims-document.ts`, so three copies of one query. The app's document + * is the source here, which also means a field added for the UI cannot silently stop being read by + * the tooling that checks it. + * + * Deliberately raw `fetch` rather than `core/io/queries.ts`: that path goes through the Effect + * client and the app's runtime config, which a bun script has no business standing up. The query + * text is what matters, and that is shared. + */ +import { print } from 'graphql'; +import { createHash } from 'node:crypto'; + +import type { DebateTranscriptSegment } from '../../core/debates/api'; +import { + AUTHORS_PROPERTY_ID, + BLOCKS_PROPERTY_ID, + CLAIM_END_OFFSET_PROPERTY_ID, + CLAIM_START_OFFSET_PROPERTY_ID, + DEBATE_CLAIMS_PROPERTY_ID, + DEBATE_TRANSCRIPTS_PROPERTY_ID, + DEBATE_TYPE_ID, + MARKDOWN_CONTENT_PROPERTY_ID, + NAME_PROPERTY_ID, +} from '../../core/debates/ontology'; +import { type DebateTranscriptClaims, groupTranscriptClaims } from '../../core/debates/transcript-claims'; +import { + type DebateTranscriptClaimsQuery, + debateTranscriptClaimsDocument, +} from '../../core/io/debate-transcript-claims-document'; + +export const API = 'https://api-testnet.geobrowser.io/graphql'; +const CHAT = 'https://chat-api-testnet.geobrowser.io'; + +/** `4c81561d1f95…` → `4c81561d-1f95-…`. The chat API wants the dashed form; the graph does not. */ +function toDashedUuid(id: string): string { + return id.replaceAll('-', '').replace(/^(.{8})(.{4})(.{4})(.{4})(.{12})$/, '$1-$2-$3-$4-$5'); +} + +export async function graphql(query: string, variables: Record): Promise { + const response = await fetch(API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }), + }); + const body = await response.json(); + if (body.errors) throw new Error(JSON.stringify(body.errors)); + return body.data as T; +} + +const ALL_DEBATES = /* GraphQL */ ` + query AllDebates($typeId: UUID!, $first: Int) { + entities(typeId: $typeId, first: $first) { + id + name + spaceIds + } + } +`; + +/** + * Every `Debate`-typed entity, with the spaces it appears in. + * + * `typeId` is a field argument on `entities`, not a field of `EntityFilter` — the filter's own + * `typeIds` returns an internal error. Same shape as `votedBy`. + */ +export async function fetchAllDebates(): Promise<{ id: string; name: string | null; spaceIds: string[] }[]> { + const data = await graphql<{ entities: { id: string; name: string | null; spaceIds: string[] }[] }>(ALL_DEBATES, { + typeId: DEBATE_TYPE_ID, + first: 1000, + }); + return data.entities; +} + +/** One debate's claims and turns, grouped exactly as the app groups them. */ +export async function fetchDebateClaims(debateEntityId: string, spaceId: string): Promise { + const data = await graphql(print(debateTranscriptClaimsDocument), { + id: debateEntityId, + transcriptsPropertyId: DEBATE_TRANSCRIPTS_PROPERTY_ID, + blocksPropertyId: BLOCKS_PROPERTY_ID, + authorsPropertyId: AUTHORS_PROPERTY_ID, + claimsPropertyId: DEBATE_CLAIMS_PROPERTY_ID, + spaceId, + namePropertyId: NAME_PROPERTY_ID, + markdownPropertyId: MARKDOWN_CONTENT_PROPERTY_ID, + offsetPropertyIds: [CLAIM_START_OFFSET_PROPERTY_ID, CLAIM_END_OFFSET_PROPERTY_ID], + }); + + return groupTranscriptClaims(data, spaceId); +} + +/** + * The debate's Whisper transcript, or none where geo-chat genuinely has none. + * + * "None" means a 404 and nothing else. Every other failure throws. + * + * This used to swallow the lot — a 500, a dropped connection, a body that would not parse — and + * hand back an empty transcript, which is indistinguishable from a debate that was never recorded. + * Downstream that is not a small lie: the planner counts every claim of that debate as unmatched + * and writes a plan that looks complete, the export drops the debate from its task files, and the + * verifier prints "no transcript" and exits 0. An outage halfway through a run would have produced + * a confident, quietly partial answer — the one outcome these scripts exist to avoid. + */ +export async function fetchTranscriptSegments(debateEntityId: string): Promise { + const url = `${CHAT}/debates/${toDashedUuid(debateEntityId)}/transcript?format=json`; + + let response: Response; + try { + response = await fetch(url); + } catch (cause) { + throw new Error(`transcript request failed for ${debateEntityId}: ${(cause as Error).message}`, { cause }); + } + + // The recording is not served, which is a real answer about this debate rather than a failure. + if (response.status === 404) return []; + if (!response.ok) throw new Error(`transcript for ${debateEntityId} returned ${response.status}`); + + let body: unknown; + try { + body = await response.json(); + } catch (cause) { + throw new Error(`transcript for ${debateEntityId} was not valid JSON`, { cause }); + } + + // A 200 whose body is not a transcript is not an empty transcript. `?.segments ?? []` said it + // was, so an error payload or a schema change came back as "this debate was never recorded" — + // the same lie as the swallowed 500 above, arriving through the one door left open. + const segments = (body as { segments?: unknown } | null)?.segments; + if (!Array.isArray(segments)) { + throw new Error(`transcript for ${debateEntityId} came back without a segments array`); + } + return segments as DebateTranscriptSegment[]; +} + +/** + * Reads `--name value` off the command line. + * + * Three scripts here take flags and each had written this out again. It is four lines, which is + * exactly the size of thing that gets copied until one copy quietly disagrees with the others. + */ +export function arg(name: string): string | undefined { + const index = process.argv.indexOf(`--${name}`); + if (index === -1) return undefined; + + // A flag that is present but has no value is a mistake, not an absence. Returning `undefined` + // sent it to the fallback, so a trailing `--limit` quietly meant "every debate" and + // `--out --limit 5` took "--limit" for the output directory — both the same shape as the + // `NaN` floor {@link numberArg} was written for, and both on scripts that plan writes. + // Blank counts as missing, not as a value. `--floor "$FLOOR"` with `FLOOR` unset expands to an + // empty argument, which looks present to `indexOf` and coerces to 0 in {@link numberArg} — the + // floor disabled by a shell variable nobody set. + const value = process.argv[index + 1]; + if (value === undefined || value.trim() === '' || value.startsWith('--')) { + console.error(`--${name} needs a value${value === undefined ? '' : `; got "${value}"`}`); + process.exit(1); + } + return value; +} + +/** + * Reads a number off the command line, refusing anything that is not one. + * + * `Number(arg('floor'))` on a typo gives `NaN`, and every comparison against `NaN` is false — so a + * mistyped `--floor` did not fail, it silently *disabled* the confidence floor and planned a write + * for every match the matcher produced. The same shape sat behind `--limit`, where it quietly meant + * "no limit" and would have walked all 80 debates. + * + * A bad flag stops the script instead. These write publish plans; a silent misreading of the one + * number that bounds them is the worst available outcome. + */ +export function numberArg( + name: string, + { fallback, min = -Infinity, max = Infinity }: { fallback: number; min?: number; max?: number } +): number { + const raw = arg(name); + if (raw === undefined) return fallback; + + // `Number('')` and `Number(' ')` are both 0, which every range here admits. Checked again + // rather than left to `arg`, because this is the guard the floor's safety rests on and it is + // exported on its own. + const value = raw.trim() === '' ? Number.NaN : Number(raw); + if (!Number.isFinite(value) || value < min || value > max) { + console.error(`--${name} must be a number between ${min} and ${max}; got "${raw}"`); + process.exit(1); + } + return value; +} + +/** + * The same guard for a positional argument, which cannot name itself in the error. + */ +export function numberAt(index: number, label: string, fallback: number, min = 0): number { + const raw = process.argv[index]; + if (raw === undefined) return fallback; + + // As in {@link numberArg}: blank coerces to 0 rather than failing. This one reads `process.argv` + // directly, so it cannot lean on `arg`. + const value = raw.trim() === '' ? Number.NaN : Number(raw); + if (!Number.isFinite(value) || value < min) { + console.error(`${label} must be a number of at least ${min}; got "${raw}"`); + process.exit(1); + } + return value; +} + +/** + * The shape of a matching task, as far as anything needs to fingerprint one. + * + * Structural rather than the task type itself: `export-claims-for-matching.ts` builds the file and + * `build-plan-from-matches.ts` reads it back, and neither should have to import the other's type + * to agree on what a version is. + */ +export type VersionableTask = { + debateEntityId: string; + turns: { + blockId: string; + segments: { i: number; startMs: number; endMs: number }[]; + claims: { claimId: string; text: string }[]; + }[]; +}; + +/** + * A short fingerprint of everything a reader's answer depends on. + * + * An answer is `(claimId, startSegment, endSegment)` and nothing else — no times, by design. That + * makes it meaningful *only* against the task file it was read from. Re-export a debate whose + * transcript has been re-cut and the same indices survive every check in + * `build-plan-from-matches.ts` — the claim id still exists, the indices are still in the turn, the + * range still ends after it starts — while pointing at different words. The script's whole promise + * is that a timecode is a real boundary of the recording; that failure keeps the promise and + * publishes the wrong boundary, which is worse than an obvious error. + * + * So the answers echo this, and a mismatch throws the file out rather than planning from it. + * + * Covers what an answer is read against: the turn a claim was filed under, the index-to-milliseconds + * mapping the answer is resolved through, and the claim text the reader matched. Deliberately not + * `published` or `matcherGuess` — those are re-read from the current task file when the plan is + * built, so a change in either leaves the reader's segment choice as good as it was. + * + * Twelve hex characters: short enough to copy into an answers file by hand, and 48 bits against a + * corpus of tens of files is not a collision anyone will meet. + */ +export function taskVersion(task: VersionableTask): string { + const shape = { + debateEntityId: task.debateEntityId, + turns: task.turns.map(turn => ({ + blockId: turn.blockId, + segments: turn.segments.map(segment => [segment.i, segment.startMs, segment.endMs]), + claims: turn.claims.map(claim => [claim.claimId, claim.text]), + })), + }; + + return createHash('sha256').update(JSON.stringify(shape)).digest('hex').slice(0, 12); +} diff --git a/apps/web/scripts/plan-claim-timecodes.ts b/apps/web/scripts/plan-claim-timecodes.ts new file mode 100644 index 0000000000..e3c2b9d52c --- /dev/null +++ b/apps/web/scripts/plan-claim-timecodes.ts @@ -0,0 +1,225 @@ +/** + * Builds the publish plan for claim timecodes across every debate that has claims. + * + * The first debate was done by hand, with four of its thirteen offsets corrected after reading the + * transcript. That does not scale to 80 debates and does not need to: the matcher that produced + * those numbers is `core/debates/claim-timing.ts`, so this runs it over everything and writes out + * the ops. + * + * Read-only. It resolves nothing about wallets and publishes nothing — it produces a JSON file for + * the publishing agent to execute. + * + * Usage: + * bun scripts/plan-claim-timecodes.ts [--floor 0.7] [--out plan.json] [--limit N] + */ +import { writeFile } from 'node:fs/promises'; + +import { resolveClaimTimings } from '../core/debates/claim-timing'; +import { + CLAIM_END_OFFSET_PROPERTY_ID, + CLAIM_START_OFFSET_PROPERTY_ID, + DEBATE_VIDEOS_PROPERTY_ID, + SELECTOR_TYPE_ID, + TARGET_PROPERTY_ID, + TYPES_PROPERTY_ID, +} from '../core/debates/ontology'; +import { API, arg, fetchAllDebates, fetchDebateClaims, fetchTranscriptSegments, numberArg } from './lib/debate-claims'; + +/** + * The confidence a match must reach before it is written to the graph. + * + * Higher than the 0.55 the live layer draws at, and deliberately so. A card is transient and a + * published offset is permanent — and worse, `published` is the resolver's *most* trusted source, + * scored 1.0, so writing a weak match does not preserve its weakness. It launders a guess into an + * exact answer that no later gate can catch, on a surface that quotes a real person saying + * something at a specific second. Leaving a claim alone costs nothing: the matcher still runs for + * it at read time, at its true confidence. + */ +const FLOOR = numberArg('floor', { fallback: 0.7, min: 0, max: 1 }); +const OUT = arg('out') ?? 'claim-timecode-plan.json'; +const LIMIT = numberArg('limit', { fallback: Infinity, min: 1 }); + +type PlannedWrite = { + entityId: string; + claimId: string; + claimText: string; + startMs: number; + endMs: number; + confidence: number; + source: string; +}; + +type DebatePlan = { + debateEntityId: string; + debateName: string | null; + spaceId: string; + claimCount: number; + writes: PlannedWrite[]; + skipped: { alreadyPublished: number; belowFloor: number; noMatch: number; restated: number }; +}; + +const debates = await fetchAllDebates(); +console.log(`debate entities: ${debates.length}`); + +const plans: DebatePlan[] = []; +const histogram = new Map(); +/** Every matched claim's score, for the floor comparison printed at the end. */ +const scores: number[] = []; +const bucket = (confidence: number) => `${(Math.floor(confidence * 10) / 10).toFixed(1)}`; +let failures = 0; + +let index = 0; +for (const debate of debates) { + if (index >= LIMIT) break; + index += 1; + + // A debate entity can be listed in several spaces; its transcript and claims live in the space it + // was published to, so try each and keep the one that actually answers. + for (const spaceId of debate.spaceIds ?? []) { + let claims; + try { + claims = await fetchDebateClaims(debate.id, spaceId); + } catch (error) { + console.error(` ! ${debate.id} in ${spaceId}: ${String(error).slice(0, 120)}`); + failures += 1; + continue; + } + if (claims.all.length === 0) continue; + + const segments = await fetchTranscriptSegments(debate.id); + const timings = resolveClaimTimings({ claims: claims.all, blocks: claims.blocks, segments }); + + const writes: PlannedWrite[] = []; + const skipped = { alreadyPublished: 0, belowFloor: 0, noMatch: 0, restated: 0 }; + + for (const claim of claims.all) { + // Stated in two turns and deduped into one row, so it carries one turn's relation entity out + // of two. Writing to that one would place half the claim and silently leave the other half + // unplaced, which is worse than leaving both to the matcher. See `restated`. + if (claim.restated) { + skipped.restated += 1; + continue; + } + // Idempotent: a claim that already carries offsets is never rewritten, so the plan can be + // regenerated and republished after a partial run without duplicating anything. + if (claim.publishedTiming !== null) { + skipped.alreadyPublished += 1; + continue; + } + const timing = timings.get(claim.id); + // `block` is the whole-turn fallback — an absence of a timecode, not a loose one. Publishing + // a 30-second window as an exact offset would be the worst of both. + if (!timing || timing.source !== 'segment') { + skipped.noMatch += 1; + continue; + } + histogram.set(bucket(timing.confidence), (histogram.get(bucket(timing.confidence)) ?? 0) + 1); + scores.push(timing.confidence); + if (timing.confidence < FLOOR) { + skipped.belowFloor += 1; + continue; + } + // Where the offsets go. It rides the same traversal the app reads, so a claim the app can + // show is a claim this can write to. + if (!claim.relationEntityId) { + skipped.noMatch += 1; + continue; + } + writes.push({ + entityId: claim.relationEntityId, + claimId: claim.id, + claimText: claim.text, + startMs: timing.startMs, + endMs: timing.endMs, + confidence: Number(timing.confidence.toFixed(3)), + source: timing.source, + }); + } + + plans.push({ + debateEntityId: debate.id, + debateName: debate.name, + spaceId, + claimCount: claims.all.length, + writes, + skipped, + }); + break; + } +} + +const totals = plans.reduce( + (sum, plan) => ({ + claims: sum.claims + plan.claimCount, + writes: sum.writes + plan.writes.length, + alreadyPublished: sum.alreadyPublished + plan.skipped.alreadyPublished, + belowFloor: sum.belowFloor + plan.skipped.belowFloor, + noMatch: sum.noMatch + plan.skipped.noMatch, + restated: sum.restated + plan.skipped.restated, + }), + { claims: 0, writes: 0, alreadyPublished: 0, belowFloor: 0, noMatch: 0, restated: 0 } +); + +const withWrites = plans.filter(plan => plan.writes.length > 0); + +console.log(`\ndebates with claims: ${plans.length} with something to publish: ${withWrites.length}`); +console.log(`claims: ${totals.claims}`); +console.log(` to publish (segment match >= ${FLOOR}): ${totals.writes}`); +console.log(` already carry offsets: ${totals.alreadyPublished}`); +console.log(` matched but below the floor: ${totals.belowFloor}`); +console.log(` no usable match (turn fallback or none):${totals.noMatch}`); +if (totals.restated > 0) console.log(` stated in two turns, left to the matcher: ${totals.restated}`); +// Non-zero exit, not just a line in the summary: this script plans writes to the graph, and a plan +// missing the debates whose claims would not load is a plan that looks complete. Same rule as +// `export-claims-for-matching.ts` and `fetchTranscriptSegments` — a failure never reads as absence. +if (failures > 0) { + console.error(` debates that failed to load: ${failures} — this plan is incomplete`); + process.exitCode = 1; +} + +console.log('\nmatch confidence, all matched claims:'); +for (const key of [...histogram.keys()].sort()) { + console.log( + ` ${key}–${(Number(key) + 0.1).toFixed(1)} ${'#'.repeat(histogram.get(key) ?? 0)} ${histogram.get(key)}` + ); +} + +// What a different floor would have published, so the choice is made with the consequence in view +// rather than by picking a round number. +const matched = [...scores].sort((a, b) => a - b); +console.log('\nwhat each floor would publish:'); +for (const floor of [0.8, 0.75, 0.7, 0.65, 0.6, 0.55, 0.5, 0.4, 0.35]) { + const count = matched.filter(score => score >= floor).length; + const share = matched.length === 0 ? 0 : Math.round((100 * count) / matched.length); + console.log( + ` >= ${floor.toFixed(2)} ${String(count).padStart(4)} ${String(share).padStart(3)}% of matched${floor === FLOOR ? ' <- this plan' : ''}` + ); +} + +const bySpace = new Map(); +for (const plan of withWrites) bySpace.set(plan.spaceId, (bySpace.get(plan.spaceId) ?? 0) + plan.writes.length); +console.log('\nwrites per space (one proposal each):'); +for (const [spaceId, count] of [...bySpace].sort((a, b) => b[1] - a[1])) console.log(` ${spaceId} ${count}`); + +await writeFile( + OUT, + `${JSON.stringify( + { + generatedAt: new Date().toISOString(), + api: API, + floor: FLOOR, + constants: { + TYPES_PROPERTY: TYPES_PROPERTY_ID, + SELECTOR_TYPE: SELECTOR_TYPE_ID, + TARGET_PROPERTY: TARGET_PROPERTY_ID, + DEBATE_VIDEOS_PROPERTY: DEBATE_VIDEOS_PROPERTY_ID, + }, + offsetProperties: { start: CLAIM_START_OFFSET_PROPERTY_ID, end: CLAIM_END_OFFSET_PROPERTY_ID }, + totals, + debates: withWrites, + }, + null, + 2 + )}\n` +); +console.log(`\nwrote ${OUT} — ${totals.writes} writes across ${withWrites.length} debates`); diff --git a/apps/web/scripts/verify-claim-timing.ts b/apps/web/scripts/verify-claim-timing.ts new file mode 100644 index 0000000000..9b61abdab6 --- /dev/null +++ b/apps/web/scripts/verify-claim-timing.ts @@ -0,0 +1,89 @@ +/** + * End-to-end check of the claim timing resolver against a live testnet debate. + * + * Runs the real grouping, the real resolver and the real ticker selection over the real published + * data, so the answer is what the UI will draw rather than what a fixture says. + * + * Usage: bun scripts/verify-claim-timing.ts [debateEntityId] [spaceId] + */ +import { claimMarkers, tickerStack, tickerWindows } from '../core/debates/claim-ticker'; +import { + claimsInSpokenOrder, + formatTimecode, + isAssertableMoment, + resolveClaimTimings, +} from '../core/debates/claim-timing'; +import { fetchDebateClaims, fetchTranscriptSegments } from './lib/debate-claims'; + +// Any debate, so the resolver can be checked where offsets were *not* published as well as where +// they were — the fallback path is the one that runs on every debate but the test one. +const DEBATE_ENTITY = (process.argv[2] ?? '01a0a60772dc7cb09bf6ebba15e97b67').replaceAll('-', ''); +const SPACE = process.argv[3] ?? '4582fbbee28a16589154f7e36f1ee3c5'; + +const claims = await fetchDebateClaims(DEBATE_ENTITY, SPACE); +console.log(`claims: ${claims.totalCount} blocks: ${claims.blocks.length}`); +console.log(`with published timecodes: ${claims.all.filter(claim => claim.publishedTiming !== null).length}`); + +const segments = await fetchTranscriptSegments(DEBATE_ENTITY); +console.log(`transcript segments: ${segments.length}`); + +const timings = resolveClaimTimings({ claims: claims.all, blocks: claims.blocks, segments }); +const ordered = claimsInSpokenOrder(claims.all, timings); + +console.log('\nAs the UI will order and label them:'); +for (const claim of ordered) { + const timing = claim.timing; + const at = timing ? formatTimecode(timing.startMs) : ' — '; + const source = timing ? timing.source : 'none'; + const score = timing ? timing.confidence.toFixed(2) : ' -- '; + console.log(` ${at.padStart(5)} [${source.padEnd(9)} ${score}] ${claim.text.slice(0, 60)}`); +} + +// The two gates, spelled out. A claim with no published offsets can still clear both on the +// strength of its transcript match alone — which is the fallback working, and also the thing worth +// knowing about, since neither surface distinguishes an inferred moment from a published one. +const bySource = new Map(); +for (const claim of ordered) + bySource.set(claim.timing?.source ?? 'none', (bySource.get(claim.timing?.source ?? 'none') ?? 0) + 1); +console.log(`\nby source: ${[...bySource].map(([key, count]) => `${key}=${count}`).join(' ')}`); +const assertable = ordered.filter(claim => isAssertableMoment(claim.timing)).length; +console.log(`firm enough to state — timecode, live card: ${assertable} of ${ordered.length}`); +// Counted directly, not as `segments - assertable`: `assertable` spans every source, and a +// published timing scores 1, so on a fully published debate that subtraction printed 0 − 13. +const belowBar = ordered.filter( + claim => claim.timing?.source === 'segment' && !isAssertableMoment(claim.timing) +).length; +console.log(`used for ordering only (matched, below the bar): ${belowBar}`); + +const windows = tickerWindows(ordered); +console.log(`\nticker-eligible claims: ${windows.length} of ${ordered.length}`); + +/** + * The debate's own length, taken from the transcript. + * + * This was hard-coded to 270s while the script accepts any debate id, so every marker's `fraction` + * was scaled against the wrong timeline and the playback sample stopped at 4:30 — on a longer + * debate it silently checked the first four and a half minutes and called that the UI. + */ +const timelineMs = segments.reduce((end, segment) => Math.max(end, segment.end_ms), 0); +if (timelineMs === 0) { + console.log('\nno transcript, so no timeline to place markers on — stopping here.'); + process.exit(0); +} +console.log(`timeline: ${(timelineMs / 1000).toFixed(0)}s`); + +const markers = claimMarkers(ordered, timelineMs); +console.log(`scrubber markers: ${markers.length}`); + +console.log('\nWhat the viewer sees, sampled every 10s:'); +for (let ms = 0; ms <= timelineMs; ms += 10_000) { + const stack = tickerStack(windows, ms); + for (const card of stack) { + console.log( + ` ${formatTimecode(ms).padStart(5)} [${card.opacity.toFixed(2)}] ${card.window.claim.text.slice(0, 56)}` + ); + } +} + +const missing = ordered.filter(claim => claim.timing === null); +console.log(`\nclaims with no moment at all: ${missing.length}`); diff --git a/apps/web/styles/styles.css b/apps/web/styles/styles.css index 37bc7b7cec..17b0fa0543 100644 --- a/apps/web/styles/styles.css +++ b/apps/web/styles/styles.css @@ -286,6 +286,12 @@ @custom-variant 2xl (@media (max-width: 1535px)); @custom-variant 3xl (@media (max-width: 1920px)); +/* Whether the viewer has a pointer that can hover, which is not the same question as how wide + their screen is. The `hover` feature describes the *primary* input, so a touchscreen laptop + reports `hover: hover` and keeps the hover affordances it can actually use — where a width + breakpoint would have taken them away and left nothing in their place. */ +@custom-variant no-hover (@media (hover: none)); + /* Container-query breakpoints for claim surfaces (GEO-2774). NOTE: no apostrophes below. Tailwind reads an apostrophe as a string delimiter even inside a comment, so an odd number of them swallows the closing marker and the whole stylesheet fails to