返回 oh-my-ppt
document-outline-scan.ts
根目录 / src / main / io / document-outline-scan.ts
1 import { fromMarkdown } from 'mdast-util-from-markdown'
2 import { gfmFromMarkdown } from 'mdast-util-gfm'
3 import { toString } from 'mdast-util-to-string'
4 import { gfm } from 'micromark-extension-gfm'
5 import type { ListItem, Nodes, Root } from 'mdast'
6 import {
7 SECTION_AGENDA_REASON_PREFIX_EN,
8 SECTION_AGENDA_REASON_PREFIX_ZH
9 } from '@shared/generation'
10
11 export interface MarkdownHeadingNode {
12 level: number
13 title: string
14 lineStart: number
15 lineEnd: number
16 charCount: number
17 bulletCount: number
18 tableCount: number
19 codeBlockCount: number
20 taskListCount: number
21 hasMetrics: boolean
22 children: MarkdownHeadingNode[]
23 }
24
25 export interface DocumentOutlineScan {
26 format: 'markdown' | 'text' | 'csv'
27 headingCount: number
28 topLevelTitle: string | null
29 sectionTree: MarkdownHeadingNode[]
30 recommendedSplitHints: string[]
31 }
32
33 export interface DocumentOutlinePageCandidate {
34 role: 'chapter-divider' | 'content'
35 title: string
36 sourceHeading: string
37 headingLevel: number
38 lineStart: number
39 lineEnd: number
40 reason: string
41 }
42
43 export interface DocumentOutlinePageCountEstimate {
44 preferredPageCount: number
45 minPageCount: number
46 maxPageCount: number
47 basis: string
48 }
49
50 const METRIC_PATTERN =
51 /(?:\d+(?:\.\d+)?\s*%|\b\d{4}\b|[$¥€]\s*\d|\d+(?:\.\d+)?\s*(?:万|亿|million|billion|k|m|bn)\b)/i
52 const HIGH_SIGNAL_PATTERN =
53 /(?:结论|风险|行动|决策|指标|增长|下降|summary|risk|action|decision|metric|growth|decline)/i
54 const STANDALONE_UNIT_TITLE_PATTERN =
55 /(?:方法|清单|模板|话术|案例|技巧|步骤|计划|复盘|指标|配置|标准|策略|架构|对比|怎么办|Q\d+|Day\s*\d+|method|checklist|template|script|case|tips|steps|plan|review|metric|strategy|workflow|standard|comparison|how to|q\d+)/i
56 const H2_OWN_BODY_SLIDE_CHAR_COUNT = 160
57 const DEEP_STANDALONE_SLIDE_CHAR_COUNT = 240
58 const DEEP_STANDALONE_HIGH_SIGNAL_CHAR_COUNT = 120
59 const MAX_PROMPT_PAGE_CANDIDATES = 500
60
61 type AstNode = Nodes | Root
62
63 const headingToLine = (node: MarkdownHeadingNode): string =>
64 `${' '.repeat(Math.max(0, node.level - 1))}- ${'#'.repeat(node.level)} ${node.title} (lines ${node.lineStart}-${node.lineEnd}, chars ${node.charCount})`
65
66 const headingSourceLabel = (heading: MarkdownHeadingNode): string =>
67 `${'#'.repeat(heading.level)} ${heading.title}`
68
69 const textContainsCjk = (value: string): boolean =>
70 /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]/.test(value)
71
72 const flattenHeadings = (nodes: MarkdownHeadingNode[]): MarkdownHeadingNode[] =>
73 nodes.flatMap((node) => [node, ...flattenHeadings(node.children)])
74
75 const meaningfulHeadings = (scan: DocumentOutlineScan | null): MarkdownHeadingNode[] =>
76 scan ? flattenHeadings(scan.sectionTree).filter((heading) => heading.title.trim().length > 0) : []
77
78 const chapterDividerHeadings = (scan: DocumentOutlineScan | null): MarkdownHeadingNode[] => {
79 const h1Headings = meaningfulHeadings(scan).filter((heading) => heading.level === 1)
80 return h1Headings.length > 1 ? h1Headings.slice(1) : []
81 }
82
83 const directBodyCharCount = (heading: MarkdownHeadingNode): number =>
84 Math.max(0, heading.charCount - heading.children.reduce((sum, child) => sum + child.charCount, 0))
85
86 const isStandaloneSlideCandidate = (heading: MarkdownHeadingNode): boolean => {
87 if (heading.level < 3) return false
88 if (heading.level === 3) {
89 return (
90 heading.charCount >= 120 ||
91 heading.bulletCount >= 1 ||
92 heading.tableCount >= 1 ||
93 heading.taskListCount >= 1 ||
94 heading.hasMetrics ||
95 STANDALONE_UNIT_TITLE_PATTERN.test(heading.title)
96 )
97 }
98 return (
99 heading.charCount >= DEEP_STANDALONE_SLIDE_CHAR_COUNT ||
100 heading.bulletCount >= 3 ||
101 heading.tableCount >= 1 ||
102 heading.taskListCount >= 2 ||
103 (heading.hasMetrics && heading.charCount >= DEEP_STANDALONE_HIGH_SIGNAL_CHAR_COUNT) ||
104 (STANDALONE_UNIT_TITLE_PATTERN.test(heading.title) &&
105 heading.charCount >= DEEP_STANDALONE_HIGH_SIGNAL_CHAR_COUNT)
106 )
107 }
108
109 const hasStandaloneSlideCandidateChild = (heading: MarkdownHeadingNode): boolean =>
110 flattenHeadings(heading.children).some(isStandaloneSlideCandidate)
111
112 const isLevel2ContentSlideCandidate = (heading: MarkdownHeadingNode): boolean =>
113 heading.level === 2 &&
114 (!hasStandaloneSlideCandidateChild(heading) ||
115 directBodyCharCount(heading) >= H2_OWN_BODY_SLIDE_CHAR_COUNT)
116
117 const hasSingleDocumentTitle = (headings: MarkdownHeadingNode[]): boolean =>
118 headings.filter((heading) => heading.level === 1).length === 1
119
120 const topLevelSectionHeadings = (headings: MarkdownHeadingNode[]): MarkdownHeadingNode[] =>
121 headings.filter((heading) => heading.level === 2)
122
123 const shouldPreferTopLevelSections = (headings: MarkdownHeadingNode[]): boolean =>
124 hasSingleDocumentTitle(headings) && topLevelSectionHeadings(headings).length > 0
125
126 const directLevel3Children = (heading: MarkdownHeadingNode): MarkdownHeadingNode[] =>
127 heading.children.filter((child) => child.level === 3)
128
129 const shouldCreateSectionAgendaPage = (heading: MarkdownHeadingNode): boolean =>
130 directLevel3Children(heading).length >= 2
131
132 const formatSectionAgendaReason = (heading: MarkdownHeadingNode): string => {
133 const allChildTitles = directLevel3Children(heading).map((child) => child.title)
134 const childTitles = allChildTitles.slice(0, 12)
135 const useChineseLabels = textContainsCjk(`${heading.title}\n${childTitles.join('\n')}`)
136 if (childTitles.length === 0) {
137 return useChineseLabels
138 ? `${SECTION_AGENDA_REASON_PREFIX_ZH}:概览本章结构。`
139 : `${SECTION_AGENDA_REASON_PREFIX_EN}: overview this chapter structure.`
140 }
141 const suffix =
142 allChildTitles.length > childTitles.length
143 ? useChineseLabels
144 ? `等共 ${allChildTitles.length} 个子主题`
145 : `and ${allChildTitles.length} child topics in total`
146 : ''
147 const joinedTitles = [childTitles.join(useChineseLabels ? '、' : ', '), suffix]
148 .filter(Boolean)
149 .join(useChineseLabels ? ',' : ', ')
150 return useChineseLabels
151 ? `${SECTION_AGENDA_REASON_PREFIX_ZH}:概览本章下的子主题,包括:${joinedTitles}。`
152 : `${SECTION_AGENDA_REASON_PREFIX_EN}: overview this chapter child topics, including: ${joinedTitles}.`
153 }
154
155 const level2CandidateLineEnd = (heading: MarkdownHeadingNode): number => {
156 if (!hasStandaloneSlideCandidateChild(heading)) return heading.lineEnd
157 const firstChildLineStart = flattenHeadings(heading.children)
158 .map((child) => child.lineStart)
159 .sort((a, b) => a - b)[0]
160 return firstChildLineStart
161 ? Math.max(heading.lineStart, firstChildLineStart - 1)
162 : heading.lineEnd
163 }
164
165 const level2AgendaLineEnd = (heading: MarkdownHeadingNode): number => {
166 const firstChildLineStart = directLevel3Children(heading)
167 .map((child) => child.lineStart)
168 .sort((a, b) => a - b)[0]
169 return firstChildLineStart
170 ? Math.max(heading.lineStart, firstChildLineStart - 1)
171 : heading.lineEnd
172 }
173
174 export const deriveOutlinePageCandidates = (
175 scan: DocumentOutlineScan | null
176 ): DocumentOutlinePageCandidate[] => {
177 const headings = meaningfulHeadings(scan)
178 if (headings.length === 0) return []
179 if (shouldPreferTopLevelSections(headings)) {
180 return topLevelSectionHeadings(headings).flatMap((heading) => {
181 const childCandidates = directLevel3Children(heading)
182 if (!shouldCreateSectionAgendaPage(heading)) {
183 return [
184 {
185 role: 'content' as const,
186 title: heading.title,
187 sourceHeading: headingSourceLabel(heading),
188 headingLevel: heading.level,
189 lineStart: heading.lineStart,
190 lineEnd: heading.lineEnd,
191 reason: 'top-level ## section in a structured document outline'
192 }
193 ]
194 }
195 return [
196 {
197 role: 'content' as const,
198 title: heading.title,
199 sourceHeading: headingSourceLabel(heading),
200 headingLevel: heading.level,
201 lineStart: heading.lineStart,
202 lineEnd: level2AgendaLineEnd(heading),
203 reason: formatSectionAgendaReason(heading)
204 },
205 ...childCandidates.map((child) => ({
206 role: 'content' as const,
207 title: child.title,
208 sourceHeading: headingSourceLabel(child),
209 headingLevel: child.level,
210 lineStart: child.lineStart,
211 lineEnd: child.lineEnd,
212 reason: `standalone level-${child.level} section`
213 }))
214 ]
215 })
216 }
217
218 let seenMeaningfulH1 = false
219
220 const candidates = headings.flatMap((heading): DocumentOutlinePageCandidate[] => {
221 if (heading.level === 1) {
222 if (!seenMeaningfulH1) {
223 seenMeaningfulH1 = true
224 return []
225 }
226 return [
227 {
228 role: 'chapter-divider',
229 title: heading.title,
230 sourceHeading: headingSourceLabel(heading),
231 headingLevel: heading.level,
232 lineStart: heading.lineStart,
233 lineEnd: heading.lineEnd,
234 reason: 'major # heading after the topic'
235 }
236 ]
237 }
238
239 if (isLevel2ContentSlideCandidate(heading)) {
240 const hasStandaloneChild = hasStandaloneSlideCandidateChild(heading)
241 return [
242 {
243 role: 'content',
244 title: heading.title,
245 sourceHeading: headingSourceLabel(heading),
246 headingLevel: heading.level,
247 lineStart: heading.lineStart,
248 lineEnd: level2CandidateLineEnd(heading),
249 reason: hasStandaloneChild
250 ? '## section has substantial own body before standalone child sections'
251 : 'leaf ## section without standalone child sections'
252 }
253 ]
254 }
255
256 if (isStandaloneSlideCandidate(heading)) {
257 return [
258 {
259 role: 'content',
260 title: heading.title,
261 sourceHeading: headingSourceLabel(heading),
262 headingLevel: heading.level,
263 lineStart: heading.lineStart,
264 lineEnd: heading.lineEnd,
265 reason: `standalone level-${heading.level} section`
266 }
267 ]
268 }
269
270 return []
271 })
272
273 return candidates
274 }
275
276 const appendHeading = (
277 roots: MarkdownHeadingNode[],
278 stack: MarkdownHeadingNode[],
279 node: MarkdownHeadingNode
280 ): void => {
281 while (stack.length > 0 && stack[stack.length - 1].level >= node.level) stack.pop()
282 const parent = stack[stack.length - 1]
283 if (parent) parent.children.push(node)
284 else roots.push(node)
285 stack.push(node)
286 }
287
288 const parseMarkdownAst = (content: string): Root =>
289 fromMarkdown(content, {
290 extensions: [gfm()],
291 mdastExtensions: [gfmFromMarkdown()]
292 }) as Root
293
294 const lineStartOf = (node: AstNode): number => node.position?.start.line ?? 1
295
296 const visitNode = (node: AstNode, visitor: (node: AstNode) => void): void => {
297 visitor(node)
298 const children = 'children' in node && Array.isArray(node.children) ? node.children : []
299 children.forEach((child) => visitNode(child as AstNode, visitor))
300 }
301
302 const collectSectionNodes = (tree: Root, heading: MarkdownHeadingNode): AstNode[] => {
303 const rootChildren = tree.children as AstNode[]
304 const startIndex = rootChildren.findIndex(
305 (node) => node.type === 'heading' && lineStartOf(node) === heading.lineStart
306 )
307 if (startIndex < 0) return []
308 const result: AstNode[] = []
309 for (const node of rootChildren.slice(startIndex + 1)) {
310 const nodeStart = lineStartOf(node)
311 if (nodeStart > heading.lineEnd) break
312 result.push(node)
313 }
314 return result
315 }
316
317 const computeHeadingStats = (node: MarkdownHeadingNode, tree: Root, lines: string[]): void => {
318 const sectionLines = lines.slice(node.lineStart - 1, node.lineEnd)
319 const sectionNodes = collectSectionNodes(tree, node)
320 let bulletCount = 0
321 let tableCount = 0
322 let codeBlockCount = 0
323 let taskListCount = 0
324
325 sectionNodes.forEach((sectionNode) => {
326 visitNode(sectionNode, (visited) => {
327 if (visited.type === 'listItem') {
328 bulletCount += 1
329 if (typeof (visited as ListItem).checked === 'boolean') taskListCount += 1
330 } else if (visited.type === 'table') {
331 tableCount += 1
332 } else if (visited.type === 'code') {
333 codeBlockCount += 1
334 }
335 })
336 })
337
338 node.charCount = sectionLines.join('\n').length
339 node.bulletCount = bulletCount
340 node.tableCount = tableCount
341 node.codeBlockCount = codeBlockCount
342 node.taskListCount = taskListCount
343 node.hasMetrics = sectionLines.some((line) => METRIC_PATTERN.test(line))
344 node.children.forEach((child) => computeHeadingStats(child, tree, lines))
345 }
346
347 export const scanDocumentOutline = (
348 content: string,
349 format: DocumentOutlineScan['format'] = 'markdown'
350 ): DocumentOutlineScan => {
351 const lines = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
352 const tree = parseMarkdownAst(content)
353 const roots: MarkdownHeadingNode[] = []
354 const stack: MarkdownHeadingNode[] = []
355 const flat: MarkdownHeadingNode[] = []
356
357 tree.children.forEach((child) => {
358 if (child.type !== 'heading') return
359 const title = toString(child).trim()
360 if (!title) return
361 const node: MarkdownHeadingNode = {
362 level: child.depth,
363 title,
364 lineStart: lineStartOf(child),
365 lineEnd: lines.length,
366 charCount: 0,
367 bulletCount: 0,
368 tableCount: 0,
369 codeBlockCount: 0,
370 taskListCount: 0,
371 hasMetrics: false,
372 children: []
373 }
374 appendHeading(roots, stack, node)
375 flat.push(node)
376 })
377
378 flat.forEach((node, index) => {
379 const nextPeerOrParent = flat.slice(index + 1).find((heading) => heading.level <= node.level)
380 node.lineEnd = nextPeerOrParent
381 ? Math.max(node.lineStart, nextPeerOrParent.lineStart - 1)
382 : lines.length
383 })
384
385 roots.forEach((node) => computeHeadingStats(node, tree, lines))
386
387 const headings = flattenHeadings(roots)
388 const h2Count = headings.filter((heading) => heading.level === 2).length
389 const chapterDividerCount = Math.max(
390 0,
391 headings.filter((heading) => heading.level === 1).length - 1
392 )
393 const standaloneSections = headings.filter(isStandaloneSlideCandidate)
394 const denseHeadings = headings.filter(
395 (heading) =>
396 heading.level >= 2 &&
397 heading.level <= 4 &&
398 (heading.charCount >= 900 ||
399 heading.children.length >= 3 ||
400 heading.bulletCount >= 6 ||
401 heading.tableCount >= 1 ||
402 heading.taskListCount >= 2 ||
403 heading.hasMetrics)
404 )
405 const recommendedSplitHints = [
406 h2Count > 0 ? `${h2Count} level-2 sections are section groups or slide candidates.` : '',
407 chapterDividerCount > 0
408 ? `${chapterDividerCount} major level-1 chapter headings should become standalone chapter divider slides.`
409 : '',
410 standaloneSections.length > 0
411 ? `Substantial level-3+ sections can be standalone slides: ${standaloneSections
412 .slice(0, 10)
413 .map((heading) => `${'#'.repeat(heading.level)} ${heading.title}`)
414 .join('; ')}.`
415 : '',
416 denseHeadings.length > 0
417 ? `Dense sections may need splitting: ${denseHeadings
418 .slice(0, 6)
419 .map((heading) => `${'#'.repeat(heading.level)} ${heading.title}`)
420 .join('; ')}.`
421 : '',
422 headings.some((heading) => HIGH_SIGNAL_PATTERN.test(heading.title))
423 ? 'Some headings contain high-signal terms such as risks, actions, decisions, metrics, or growth.'
424 : ''
425 ].filter(Boolean)
426
427 return {
428 format,
429 headingCount: headings.length,
430 topLevelTitle: headings.find((heading) => heading.level === 1)?.title || null,
431 sectionTree: roots,
432 recommendedSplitHints
433 }
434 }
435
436 export const formatDocumentOutlineScanForPrompt = (
437 scan: DocumentOutlineScan | null,
438 pageCandidatesOverride?: DocumentOutlinePageCandidate[]
439 ): string => {
440 if (!scan) return ''
441 const headings = flattenHeadings(scan.sectionTree)
442 const pageCandidates = pageCandidatesOverride ?? deriveOutlinePageCandidates(scan)
443 const pageCountEstimate = estimateOutlinePageCount(scan, pageCandidates)
444 const chapterDividers = chapterDividerHeadings(scan)
445 if (headings.length === 0) {
446 return [
447 'Document structure scan:',
448 `- Format: ${scan.format}`,
449 '- Markdown headings detected: 0',
450 '- No heading hierarchy was detected; split by paragraphs, list blocks, tables, metrics, and semantic transitions.'
451 ].join('\n')
452 }
453
454 const visibleHeadings = headings.slice(0, 80)
455 const omittedHeadingCount = Math.max(0, headings.length - visibleHeadings.length)
456 const visiblePageCandidates = pageCandidates.slice(0, MAX_PROMPT_PAGE_CANDIDATES)
457 const omittedPageCandidateCount = Math.max(
458 0,
459 pageCandidates.length - visiblePageCandidates.length
460 )
461 const pageCandidatePromptCount = visiblePageCandidates.length
462
463 return [
464 'Document structure scan:',
465 `- Format: ${scan.format}`,
466 `- Markdown headings detected: ${scan.headingCount}`,
467 scan.topLevelTitle ? `- Top-level title: ${scan.topLevelTitle}` : '',
468 pageCountEstimate
469 ? `- Deterministic slide-count estimate: prefer ${pageCountEstimate.preferredPageCount} slides; acceptable range ${pageCountEstimate.minPageCount}-${pageCountEstimate.maxPageCount}. ${pageCountEstimate.basis}`
470 : '',
471 chapterDividers.length > 0
472 ? `- Chapter divider slides: ${chapterDividers
473 .slice(0, 12)
474 .map((heading) => `# ${heading.title}`)
475 .join(
476 '; '
477 )}${chapterDividers.length > 12 ? '; ...' : ''}. Keep these as standalone section-divider pages.`
478 : '',
479 pageCandidates.length > 0
480 ? omittedPageCandidateCount > 0
481 ? `- Page candidate skeleton (${pageCandidatePromptCount} visible of ${pageCandidates.length} candidates): Use the visible candidates as the authoritative first-pass outline when the user did not provide pageCount. Return pageCount=${pageCandidatePromptCount}; later slide generation will inspect source passages again.`
482 : `- Page candidate skeleton (${pageCandidates.length} slides): Use this as the authoritative first-pass outline when the user did not provide pageCount. Do not reread every candidate before returning; later slide generation will inspect source passages again.`
483 : '',
484 ...visiblePageCandidates.map(
485 (candidate, index) =>
486 ` ${index + 1}. [${candidate.role}] ${candidate.sourceHeading} (lines ${candidate.lineStart}-${candidate.lineEnd}; ${candidate.reason})`
487 ),
488 omittedPageCandidateCount > 0
489 ? `- Page candidate skeleton truncated: ${omittedPageCandidateCount} additional candidates were omitted from this parse prompt to keep parsing bounded.`
490 : '',
491 '- Heading map:',
492 ...visibleHeadings.map(headingToLine),
493 omittedHeadingCount > 0
494 ? `- Heading map truncated: ${omittedHeadingCount} additional headings were omitted from this single-shot parse prompt.`
495 : '',
496 scan.recommendedSplitHints.length > 0 ? '- Split/merge hints:' : '',
497 ...scan.recommendedSplitHints.map((hint) => ` - ${hint}`)
498 ]
499 .filter(Boolean)
500 .join('\n')
501 }
502
503 export const scanHasMultipleSlideCandidates = (scan: DocumentOutlineScan | null): boolean => {
504 if (!scan) return false
505 const headings = meaningfulHeadings(scan)
506 const h2Count = headings.filter((heading) => heading.level === 2).length
507 const standaloneSectionCount = headings.filter(isStandaloneSlideCandidate).length
508 return h2Count >= 2 || standaloneSectionCount >= 2 || headings.length >= 4
509 }
510
511 export const scanHeadingTitles = (scan: DocumentOutlineScan | null): string[] =>
512 meaningfulHeadings(scan).map((heading) => heading.title)
513
514 export const estimateOutlinePageCount = (
515 scan: DocumentOutlineScan | null,
516 pageCandidatesOverride?: DocumentOutlinePageCandidate[]
517 ): DocumentOutlinePageCountEstimate | null => {
518 const headings = meaningfulHeadings(scan)
519 if (headings.length === 0) return null
520 const h2Count = headings.filter((heading) => heading.level === 2).length
521 const standaloneSectionCount = headings.filter(isStandaloneSlideCandidate).length
522 const chapterDividerCount = chapterDividerHeadings(scan).length
523 const h2ContentPageCount = headings.filter(isLevel2ContentSlideCandidate).length
524 const pageCandidates = pageCandidatesOverride ?? deriveOutlinePageCandidates(scan)
525 const preferTopLevelSections = shouldPreferTopLevelSections(headings)
526 const topLevelSections = topLevelSectionHeadings(headings)
527 const sectionAgendaPageCount = topLevelSections.filter(
528 shouldCreateSectionAgendaPage
529 ).length
530 const directLevel3PageCount = topLevelSections.filter(shouldCreateSectionAgendaPage).reduce(
531 (sum, heading) => sum + directLevel3Children(heading).length,
532 0
533 )
534 const nonAgendaTopLevelSectionCount = topLevelSections.length - sectionAgendaPageCount
535
536 const naturalSectionCount =
537 preferTopLevelSections
538 ? sectionAgendaPageCount + directLevel3PageCount + nonAgendaTopLevelSectionCount
539 : h2Count > 0
540 ? h2ContentPageCount + standaloneSectionCount
541 : Math.max(
542 chapterDividerCount,
543 Math.ceil(headings.filter((heading) => heading.level >= 3).length / 3),
544 1
545 )
546 const preferredPageCount = Math.max(
547 1,
548 Math.min(
549 MAX_PROMPT_PAGE_CANDIDATES,
550 pageCandidates.length > 0 ? pageCandidates.length : chapterDividerCount + naturalSectionCount
551 )
552 )
553 const minPageCount =
554 preferredPageCount <= 3
555 ? preferredPageCount
556 : Math.max(2, Math.floor(preferredPageCount * 0.85))
557 const maxPageCount =
558 preferredPageCount <= 3
559 ? Math.min(MAX_PROMPT_PAGE_CANDIDATES, preferredPageCount + 1)
560 : Math.min(MAX_PROMPT_PAGE_CANDIDATES, Math.ceil(preferredPageCount * 1.15))
561
562 return {
563 preferredPageCount,
564 minPageCount,
565 maxPageCount,
566 basis: preferTopLevelSections
567 ? `Based on ${h2Count} top-level level-2 document sections, including ${sectionAgendaPageCount} section agenda pages with at least 2 child sections and ${directLevel3PageCount} direct level-3 content pages${pageCandidates.length > MAX_PROMPT_PAGE_CANDIDATES ? `, capped to ${MAX_PROMPT_PAGE_CANDIDATES} visible page candidates for parsing` : ''}.`
568 : `Based on ${chapterDividerCount} chapter divider headings, ${h2ContentPageCount} level-2 content slide candidates, and ${standaloneSectionCount} standalone level-3+ slide candidates${pageCandidates.length > MAX_PROMPT_PAGE_CANDIDATES ? `, capped to ${MAX_PROMPT_PAGE_CANDIDATES} visible page candidates for parsing` : ''}.`
569 }
570 }
571
571 lines TYPESCRIPT