feat(pdf): improve cross-page block stitching and enforce TTS segment source boundaries

Enhance PDF cross-page block stitching to move only sentence continuations and
preserve remaining text on the following page. Update TTS segment planning to
support strict source boundary enforcement, ensuring segments do not cross
block or paragraph-title boundaries. Add new tests for both features and
refactor PDF text item normalization to filter out skewed or rotated runs.
This commit is contained in:
Richard R 2026-05-18 13:21:43 -06:00
parent 90e7caac43
commit 6ab5c230c2
10 changed files with 479 additions and 56 deletions

View file

@ -16,8 +16,8 @@ import {
clampTtsSegmentMaxBlockLength,
} from '@/types/config';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { Section, ToggleRow, buttonClass, segmentedButtonClass, segmentedGroupClass } from '@/components/formPrimitives';
import { RefreshIcon } from '@/components/icons/Icons';
import { Section, ToggleRow, CheckItem, buttonClass, segmentedButtonClass, segmentedGroupClass } from '@/components/formPrimitives';
import { RefreshIcon, SparkleIcon } from '@/components/icons/Icons';
import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf';
const PDF_SKIP_KIND_OPTIONS: Array<{ kind: ParsedPdfBlockKind; label: string }> = [
@ -422,19 +422,27 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
title="PDF Structure"
subtitle="Layout-aware parsing controls."
variant="flat"
action={
<div className="flex flex-col items-end gap-1">
<span className="flex items-center gap-1 text-muted">
<SparkleIcon className="h-3 w-3 text-accent/70" />
<span className="text-xs">PP-DocLayout-V3</span>
</span>
<span className="flex items-center gap-1 text-xs text-muted">
<span>{pdf.parseStatus ?? 'pending'}</span>
<button
type="button"
className={buttonClass({ variant: 'ghost', size: 'icon', className: '!h-5 !w-5 hover:scale-[1.1] shrink-0' })}
onClick={pdf.onForceReparse}
disabled={!computeAvailable || pdf.parseStatus === 'running' || pdf.parseStatus === 'pending'}
title="Force reparse"
>
<RefreshIcon className={`h-3 w-3 ${pdf.parseStatus === 'running' || pdf.parseStatus === 'pending' ? 'animate-spin' : ''}`} />
</button>
</span>
</div>
}
>
<div className="flex items-center gap-1.5 text-xs text-muted pb-1">
Parse status: <span className="text-foreground font-medium">{pdf.parseStatus ?? 'pending'}</span>
<button
type="button"
className={buttonClass({ variant: 'ghost', size: 'icon', className: '!h-5 !w-5 hover:scale-[1.1] shrink-0' })}
onClick={pdf.onForceReparse}
disabled={!computeAvailable || pdf.parseStatus === 'running' || pdf.parseStatus === 'pending'}
title="Force reparse"
>
<RefreshIcon className="h-3 w-3" />
</button>
</div>
<ToggleRow
label="Show block overlay"
description="Render detected block boxes and labels on the page."
@ -451,21 +459,18 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
disabled={!computeAvailable}
variant="flat"
/>
<div className="space-y-2 pt-1">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted">Skip while reading aloud</p>
{PDF_SKIP_KIND_OPTIONS.map((option) => {
const checked = pdf.skipBlockKinds.includes(option.kind);
return (
<ToggleRow
<div className="pt-1">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted pb-1.5">Skip while reading aloud</p>
<div className="grid grid-cols-2 gap-x-3">
{PDF_SKIP_KIND_OPTIONS.map((option) => (
<CheckItem
key={option.kind}
label={option.label}
description={`Exclude ${option.label.toLowerCase()} from TTS/audiobook output.`}
checked={checked}
checked={pdf.skipBlockKinds.includes(option.kind)}
onChange={(enabled) => pdf.onToggleSkipKind(option.kind, enabled)}
variant="flat"
/>
);
})}
))}
</div>
</div>
</Section>
)}

View file

@ -1,6 +1,6 @@
'use client';
import type { ReactNode } from 'react';
import { useId, type ReactNode } from 'react';
/**
* Shared compact form primitives used by settings-like surfaces across
@ -78,7 +78,7 @@ export function Section({
variant = 'panel',
}: {
title: string;
subtitle?: string;
subtitle?: ReactNode;
children: ReactNode;
action?: ReactNode;
variant?: 'panel' | 'flat';
@ -130,6 +130,65 @@ export function Card({
);
}
export type SwitchSize = 'sm' | 'md';
const SWITCH_SIZE: Record<SwitchSize, { track: string; thumb: string; on: string; off: string }> = {
sm: {
track: 'h-4 w-7',
thumb: 'h-3 w-3',
on: 'translate-x-3',
off: 'translate-x-0.5',
},
md: {
track: 'h-5 w-9',
thumb: 'h-4 w-4',
on: 'translate-x-4',
off: 'translate-x-0.5',
},
};
export function Switch({
checked,
onChange,
disabled = false,
size = 'md',
ariaLabel,
ariaLabelledBy,
ariaDescribedBy,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
size?: SwitchSize;
ariaLabel?: string;
ariaLabelledBy?: string;
ariaDescribedBy?: string;
}) {
const s = SWITCH_SIZE[size];
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative inline-flex shrink-0 cursor-pointer items-center rounded-full border border-offbase transition-colors duration-200 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${s.track} ${
checked ? 'bg-accent' : 'bg-offbase'
}`}
>
<span
aria-hidden="true"
className={`pointer-events-none inline-block rounded-full bg-background shadow-sm ring-0 transition-transform duration-200 ease-out ${s.thumb} ${
checked ? s.on : s.off
}`}
/>
</button>
);
}
export function ToggleRow({
label,
description,
@ -147,32 +206,76 @@ export function ToggleRow({
right?: ReactNode;
variant?: 'card' | 'flat';
}) {
const labelId = useId();
const descId = useId();
const rowClass =
variant === 'flat'
? 'px-0.5 pt-1 pb-2 border-b border-offbase last:border-b-0 transition-transform duration-200 ease-out hover:scale-[1.003]'
: 'rounded-md border border-offbase bg-background px-2.5 py-1.5 transition-transform duration-200 ease-out hover:scale-[1.005]';
const handleTextToggle = () => {
if (!disabled) onChange(!checked);
};
return (
<div className={rowClass}>
<div className="flex items-start gap-2.5">
<label className="flex items-start gap-2.5 flex-1 min-w-0">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
className="shrink-0 form-checkbox h-4 w-4 text-accent rounded border-muted disabled:opacity-50 disabled:cursor-not-allowed"
/>
<span className="space-y-0.5 min-w-0">
<span className="block text-sm font-medium leading-5 text-foreground">{label}</span>
<span className="block text-xs leading-4 text-muted">{description}</span>
</span>
</label>
<div
className={`flex-1 min-w-0 space-y-0.5 ${disabled ? '' : 'cursor-pointer'}`}
onClick={handleTextToggle}
>
<span id={labelId} className="block text-sm font-medium leading-5 text-foreground">{label}</span>
<span id={descId} className="block text-xs leading-4 text-muted">{description}</span>
</div>
{right ? <div className="shrink-0 self-start pl-1.5">{right}</div> : null}
<Switch
checked={checked}
onChange={onChange}
disabled={disabled}
size="md"
ariaLabelledBy={labelId}
ariaDescribedBy={descId}
/>
</div>
</div>
);
}
export function CheckItem({
label,
checked,
onChange,
disabled = false,
}: {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}) {
const labelId = useId();
const handleTextToggle = () => {
if (!disabled) onChange(!checked);
};
return (
<div className="flex items-center justify-between gap-2 py-0.5 group">
<span
id={labelId}
onClick={handleTextToggle}
className={`flex-1 min-w-0 truncate text-xs leading-4 text-foreground select-none transition-colors duration-200 ease-out group-hover:text-accent ${
disabled ? '' : 'cursor-pointer'
}`}
>
{label}
</span>
<Switch
checked={checked}
onChange={onChange}
disabled={disabled}
size="sm"
ariaLabelledBy={labelId}
/>
</div>
);
}
export function Field({
label,
hint,

View file

@ -620,3 +620,19 @@ export function FileSettingsIcon(props: React.SVGProps<SVGSVGElement>) {
</svg>
);
}
export function SparkleIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
className={props.className}
width={props.width || '1em'}
height={props.height || '1em'}
{...props}
>
<path d="M8 1c.2 0 .38.13.44.32L9.6 5.4l4.07 1.16a.46.46 0 0 1 0 .88L9.6 8.6 8.44 12.68a.46.46 0 0 1-.88 0L6.4 8.6 2.33 7.44a.46.46 0 0 1 0-.88L6.4 5.4 7.56 1.32A.46.46 0 0 1 8 1ZM3 1a.35.35 0 0 1 .34.24l.6 1.82 1.82.6a.35.35 0 0 1 0 .68l-1.82.6-.6 1.82a.35.35 0 0 1-.68 0l-.6-1.82L.24 4.34a.35.35 0 0 1 0-.68l1.82-.6.6-1.82A.35.35 0 0 1 3 1ZM12 9a.35.35 0 0 1 .34.24l.6 1.82 1.82.6a.35.35 0 0 1 0 .68l-1.82.6-.6 1.82a.35.35 0 0 1-.68 0l-.6-1.82-1.82-.6a.35.35 0 0 1 0-.68l1.82-.6.6-1.82A.35.35 0 0 1 12 9Z" />
</svg>
);
}

View file

@ -360,6 +360,37 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) {
}
for (const list of map.values()) {
const geoKey = (entry: {
block: ParsedPdfBlock;
fragment: ParsedPdfBlock['fragments'][number];
isContinuation: boolean;
}): string => {
const [x0, y0, x1, y1] = entry.fragment.bbox;
const round = (value: number) => Math.round(value * 10) / 10;
return [
entry.block.kind,
round(x0),
round(y0),
round(x1),
round(y1),
].join(':');
};
const continuationGeometry = new Set<string>();
for (const entry of list) {
if (entry.isContinuation) {
continuationGeometry.add(geoKey(entry));
}
}
const filtered = list.filter((entry) => {
if (entry.isContinuation) return true;
return !continuationGeometry.has(geoKey(entry));
});
list.length = 0;
list.push(...filtered);
list.sort((a, b) => {
if (a.fragment.readingOrder !== b.fragment.readingOrder) {
return a.fragment.readingOrder - b.fragment.readingOrder;

View file

@ -15,9 +15,21 @@ interface ParsePdfInput {
const LAYOUT_RENDER_SCALE = 1.5;
function normalizeTextItems(items: TextItem[], pageHeight: number): PdfTextItem[] {
export function normalizeTextItemsForLayout(items: TextItem[], pageHeight: number): PdfTextItem[] {
return items
.filter((item) => typeof item.str === 'string' && item.str.trim().length > 0)
.filter((item) => {
if (!(typeof item.str === 'string' && item.str.trim().length > 0)) return false;
const transform = item.transform;
if (!Array.isArray(transform) || transform.length < 6) return false;
// Reject heavily skewed/rotated text runs (e.g. vertical margin labels
// such as arXiv metadata) so they do not get merged into body blocks.
const skewX = Number(transform[1] ?? 0);
const skewY = Number(transform[2] ?? 0);
if (Math.abs(skewX) > 0.5 || Math.abs(skewY) > 0.5) return false;
return true;
})
.map((item) => {
const x = Number(item.transform[4] ?? 0);
const width = Math.max(0, Number(item.width ?? 0));
@ -71,7 +83,7 @@ export async function parsePdf(input: ParsePdfInput): Promise<ParsedPdfDocument>
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale: 1.0 });
const textContent = await page.getTextContent();
const textItems = normalizeTextItems(
const textItems = normalizeTextItemsForLayout(
textContent.items.filter((item): item is TextItem => 'str' in item && 'transform' in item),
viewport.height,
);

View file

@ -28,6 +28,39 @@ function canStitch(a: ParsedPdfBlock, b: ParsedPdfBlock): boolean {
return true;
}
function splitHeadContinuation(text: string): { continuation: string; remainder: string } {
const normalized = text.replace(/\s+/g, ' ').trim();
if (!normalized) return { continuation: '', remainder: '' };
const CLOSERS = new Set(['"', "'", '”', '', ')', ']', '}']);
const isTerminal = (ch: string): boolean => ch === '.' || ch === '!' || ch === '?';
for (let i = 0; i < normalized.length; i += 1) {
const ch = normalized[i];
if (!isTerminal(ch)) continue;
const prev = i > 0 ? normalized[i - 1] : '';
const next = i + 1 < normalized.length ? normalized[i + 1] : '';
if (ch === '.' && /\d/.test(prev) && /\d/.test(next)) continue;
let cut = i + 1;
while (cut < normalized.length && CLOSERS.has(normalized[cut])) cut += 1;
const after = cut < normalized.length ? normalized[cut] : '';
if (!after || /\s/.test(after) || /[A-Z]/.test(after)) {
return {
continuation: normalized.slice(0, cut).trim(),
remainder: normalized.slice(cut).trim(),
};
}
}
return {
continuation: normalized,
remainder: '',
};
}
const HARD_BOUNDARY_KINDS = new Set<ParsedPdfBlock['kind']>([
'paragraph_title',
'doc_title',
@ -81,9 +114,27 @@ export function stitchCrossPageBlocks(doc: ParsedPdfDocument): ParsedPdfDocument
if (!tail || !head) continue;
if (!canStitch(tail, head)) continue;
tail.fragments.push(...head.fragments);
tail.text = `${tail.text} ${head.text}`.replace(/\s+/g, ' ').trim();
next.blocks.splice(headIndex, 1);
const { continuation, remainder } = splitHeadContinuation(head.text);
if (!continuation) continue;
const continuationFragment = head.fragments[0]
? { ...head.fragments[0], text: continuation }
: null;
if (continuationFragment) {
tail.fragments.push(continuationFragment);
}
tail.text = `${tail.text} ${continuation}`.replace(/\s+/g, ' ').trim();
if (!remainder) {
next.blocks.splice(headIndex, 1);
continue;
}
head.text = remainder;
if (head.fragments[0]) {
head.fragments[0].text = remainder;
}
}
return {

View file

@ -188,6 +188,7 @@ export function planCanonicalTtsSegments(
const readerType = options.readerType ?? 'pdf';
const enforceSourceBoundaries = Boolean(options.enforceSourceBoundaries);
const keyPrefix = options.keyPrefix ?? TTS_SEGMENT_PLAN_VERSION;
const sourceSeparator = enforceSourceBoundaries ? '\n\n' : ' ';
const preparedSources: PreparedSourceUnit[] = [];
const textParts: string[] = [];
let combinedLength = 0;
@ -197,8 +198,8 @@ export function planCanonicalTtsSegments(
if (!text) continue;
if (textParts.length > 0) {
textParts.push(' ');
combinedLength += 1;
textParts.push(sourceSeparator);
combinedLength += sourceSeparator.length;
}
const startOffset = combinedLength;
@ -215,9 +216,73 @@ export function planCanonicalTtsSegments(
const canonicalText = textParts.join('');
const splitOptions = { maxBlockLength: options.maxBlockLength };
const blocks = readerType === 'epub'
? splitTextToTtsBlocksEPUB(canonicalText, splitOptions)
: splitTextToTtsBlocks(canonicalText, splitOptions);
const splitIntoBlocks = (text: string): string[] =>
readerType === 'epub'
? splitTextToTtsBlocksEPUB(text, splitOptions)
: splitTextToTtsBlocks(text, splitOptions);
if (enforceSourceBoundaries) {
const segments: CanonicalTtsSegment[] = [];
for (const source of preparedSources) {
const localBlocks = splitIntoBlocks(source.text);
let localRawCursor = 0;
let localNormalizedCursor = 0;
for (const block of localBlocks) {
const text = block.trim();
if (!text) continue;
const exactStart = source.text.indexOf(text, localRawCursor);
let localStart: number;
let localEnd: number;
if (exactStart >= 0) {
localStart = exactStart;
localEnd = exactStart + text.length;
localRawCursor = localEnd;
localNormalizedCursor = normalizeWithRawMap(source.text.slice(0, localEnd)).text.length;
} else {
const flexible = findFlexibleOffset(source.text, text, localNormalizedCursor);
if (flexible) {
localStart = flexible.start;
localEnd = flexible.end;
localRawCursor = localEnd;
localNormalizedCursor = flexible.normalizedEnd;
} else {
// Never drop blocks in enforced boundary mode.
localStart = Math.max(0, Math.min(localRawCursor, source.text.length));
localEnd = Math.max(localStart, Math.min(source.text.length, localStart + text.length));
localRawCursor = localEnd;
localNormalizedCursor = normalizeWithRawMap(source.text.slice(0, localEnd)).text.length;
}
}
const absoluteStart = source.startOffset + localStart;
const absoluteEnd = source.startOffset + localEnd;
const ordinal = segments.length;
segments.push({
key: buildSegmentKey(keyPrefix, text),
ordinal,
text,
ownerSourceKey: source.sourceKey,
ownerLocator: source.locator,
startAnchor: anchorForOffset(source, absoluteStart),
endAnchor: anchorForOffset(source, absoluteEnd),
spansSourceBoundary: false,
});
}
}
return {
version: TTS_SEGMENT_PLAN_VERSION,
readerType,
text: canonicalText,
segments,
};
}
const blocks = splitIntoBlocks(canonicalText);
let rawCursor = 0;
let normalizedCursor = 0;
@ -238,11 +303,33 @@ export function planCanonicalTtsSegments(
normalizedCursor = normalizeWithRawMap(canonicalText.slice(0, endOffset)).text.length;
} else {
const flexible = findFlexibleOffset(canonicalText, text, normalizedCursor);
if (!flexible) continue;
startOffset = flexible.start;
endOffset = flexible.end;
rawCursor = endOffset;
normalizedCursor = flexible.normalizedEnd;
if (!flexible) {
if (!enforceSourceBoundaries) continue;
// In enforced-boundary mode (PDF block source units), never drop a
// split block just because canonical rematching failed. Prefer a
// best-effort anchor inside the source that the cursor currently sits
// in, then emit the segment text as-is.
const fallbackSource = findSourceForOffset(preparedSources, rawCursor, 'start')
?? preparedSources[preparedSources.length - 1]
?? null;
if (!fallbackSource) continue;
const fallbackStart = Math.max(fallbackSource.startOffset, Math.min(rawCursor, fallbackSource.endOffset));
const fallbackEnd = Math.max(
fallbackStart,
Math.min(fallbackSource.endOffset, fallbackStart + text.length),
);
startOffset = fallbackStart;
endOffset = fallbackEnd;
rawCursor = fallbackEnd;
normalizedCursor = normalizeWithRawMap(canonicalText.slice(0, fallbackEnd)).text.length;
} else {
startOffset = flexible.start;
endOffset = flexible.end;
rawCursor = endOffset;
normalizedCursor = flexible.normalizedEnd;
}
}
const ownerSource = findSourceForOffset(preparedSources, startOffset, 'start');

View file

@ -0,0 +1,40 @@
import { expect, test } from '@playwright/test';
import { normalizeTextItemsForLayout } from '../../src/lib/server/pdf-layout/parsePdf';
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
function makeTextItem(
str: string,
transform: [number, number, number, number, number, number],
width = 100,
): TextItem {
return {
str,
transform,
width,
height: Math.abs(transform[3]),
dir: 'ltr',
fontName: 'test',
hasEOL: false,
} as unknown as TextItem;
}
test.describe('normalizeTextItemsForLayout', () => {
test('keeps horizontal body text and drops rotated/skewed margin text', () => {
const horizontal = makeTextItem(
'Powered by large language models',
[10, 0, 0, 10, 100, 600],
);
// Typical 90deg-ish rotated/skewed run (like side metadata labels).
const rotated = makeTextItem(
'arXiv:2407.16741v3 [cs.SE] 18 Apr 2025',
[0, 10, -10, 0, 30, 400],
);
const normalized = normalizeTextItemsForLayout([horizontal, rotated], 800);
expect(normalized).toHaveLength(1);
expect(normalized[0]?.text).toBe('Powered by large language models');
});
});

View file

@ -57,6 +57,27 @@ test.describe('stitchCrossPageBlocks', () => {
expect(page2?.blocks.map((b) => b.id)).toEqual(['b3']);
});
test('moves only the continuation sentence and keeps remaining text on next page', () => {
const doc = makeDoc(
[
makeBlock('b1', 'text', 'This sentence continues', 1, 0),
],
[
makeBlock('b2', 'text', 'into the next page. This should stay on page two.', 2, 0),
],
);
const stitched = stitchCrossPageBlocks(doc);
const page1 = stitched.pages[0];
const page2 = stitched.pages[1];
expect(page1?.blocks[0]?.text).toBe('This sentence continues into the next page.');
expect(page1?.blocks[0]?.fragments).toHaveLength(2);
expect(page2?.blocks).toHaveLength(1);
expect(page2?.blocks[0]?.id).toBe('b2');
expect(page2?.blocks[0]?.text).toBe('This should stay on page two.');
});
test('does not stitch across paragraph-title boundary', () => {
const doc = makeDoc(
[

View file

@ -164,6 +164,63 @@ test.describe('planCanonicalTtsSegments', () => {
expect(plan.segments).toHaveLength(1);
expect(plan.segments[0].ownerSourceKey).toBe('page:1');
});
test('keeps paragraph-title boundaries when source boundaries are enforced', () => {
const plan = planCanonicalTtsSegments([
{
sourceKey: 'abstract',
locator: { page: 1, readerType: 'pdf', blockId: 'a1' },
text: 'Released under the permissive MIT license, OpenHands is a community project spanning academia and industry with more than 2.1K contributions.',
},
{
sourceKey: 'intro-title',
locator: { page: 1, readerType: 'pdf', blockId: 't1' },
text: '1 INTRODUCTION',
},
{
sourceKey: 'intro-body',
locator: { page: 1, readerType: 'pdf', blockId: 'p1' },
text: 'Powered by large language models (LLMs; OpenAI 2024b; Team et al. 2023), user-facing AI systems have become increasingly capable of performing complex tasks such as accurately responding to user queries, solving math problems, and generating code.',
},
], {
readerType: 'pdf',
maxBlockLength: 450,
keyPrefix: 'doc:v1',
enforceSourceBoundaries: true,
});
expect(plan.segments.some((segment) => segment.ownerSourceKey === 'intro-title' && segment.text === '1 INTRODUCTION')).toBeTruthy();
expect(plan.segments.some((segment) => segment.ownerSourceKey === 'intro-body' && segment.text.startsWith('Powered by large language models'))).toBeTruthy();
expect(plan.segments.some((segment) => segment.text.startsWith('1 INTRODUCTION Powered by'))).toBeFalsy();
});
test('does not drop first sentence when canonical rematch fails in enforced boundary mode', () => {
const plan = planCanonicalTtsSegments([
{
sourceKey: 'title',
locator: { page: 1, readerType: 'pdf', blockId: 't1' },
text: '1 INTRODUCTION',
},
{
sourceKey: 'intro',
locator: { page: 1, readerType: 'pdf', blockId: 'p1' },
// Missing whitespace after sentence terminal is a common PDF extraction artifact.
text: 'Powered by large language models have become increasingly capable of generating code.In particular, AI agents have recently received ever-increasing research focus.',
},
], {
readerType: 'pdf',
maxBlockLength: 450,
keyPrefix: 'doc:v1',
enforceSourceBoundaries: true,
});
const introSegments = plan.segments
.filter((segment) => segment.ownerSourceKey === 'intro')
.map((segment) => segment.text);
const combinedIntro = introSegments.join(' ');
expect(combinedIntro.includes('Powered by large language models')).toBeTruthy();
expect(combinedIntro.includes('In particular, AI agents')).toBeTruthy();
});
});
test.describe('buildSegmentKeyPrefix / buildSegmentKey contract', () => {