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