| 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 |