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