| 1 | import { |
| 2 | SECTION_AGENDA_OUTLINE_MARKER, |
| 3 | isInternalDocumentPlanPageReason, |
| 4 | isSectionAgendaReason, |
| 5 | type OutlineItem, |
| 6 | type SourceDocumentPlan |
| 7 | } from '@shared/generation' |
| 8 | |
| 9 | const MAX_SOURCE_PLAN_PAGES = 500 |
| 10 | const LAYOUT_INTENTS = new Set([ |
| 11 | 'cover', |
| 12 | 'data-focus', |
| 13 | 'comparison', |
| 14 | 'timeline', |
| 15 | 'concept', |
| 16 | 'process', |
| 17 | 'summary', |
| 18 | 'quote', |
| 19 | 'image-focus' |
| 20 | ]) |
| 21 | |
| 22 | const getObject = (value: unknown): Record<string, unknown> | null => |
| 23 | value && typeof value === 'object' && !Array.isArray(value) |
| 24 | ? (value as Record<string, unknown>) |
| 25 | : null |
| 26 | |
| 27 | const readPositiveInt = (value: unknown): number | null => { |
| 28 | const n = Number(value) |
| 29 | return Number.isFinite(n) && n >= 1 ? Math.floor(n) : null |
| 30 | } |
| 31 | |
| 32 | const readString = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') |
| 33 | |
| 34 | const normalizeSourcePlanReason = (reason: string): string => |
| 35 | reason && !isInternalDocumentPlanPageReason(reason) ? reason : '' |
| 36 | |
| 37 | const normalizeSourcePlanItem = (value: unknown, fallbackPageNumber: number) => { |
| 38 | const record = getObject(value) |
| 39 | if (!record) return null |
| 40 | const pageNumber = readPositiveInt(record.pageNumber) ?? fallbackPageNumber |
| 41 | const title = readString(record.title) || `Slide ${pageNumber}` |
| 42 | const role = record.role === 'chapter-divider' ? 'chapter-divider' : 'content' |
| 43 | const sourceHeading = readString(record.sourceHeading) |
| 44 | const headingLevel = readPositiveInt(record.headingLevel) ?? 1 |
| 45 | const lineStart = readPositiveInt(record.lineStart) ?? 1 |
| 46 | const lineEnd = readPositiveInt(record.lineEnd) ?? lineStart |
| 47 | const reason = readString(record.reason) |
| 48 | if (!sourceHeading || lineEnd < lineStart) return null |
| 49 | return { |
| 50 | pageNumber, |
| 51 | title, |
| 52 | role, |
| 53 | sourceHeading, |
| 54 | headingLevel, |
| 55 | lineStart, |
| 56 | lineEnd, |
| 57 | reason: normalizeSourcePlanReason(reason) |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | export const normalizeSourcePlan = (value: unknown): SourceDocumentPlan | null => { |
| 62 | const record = getObject(value) |
| 63 | if (!record) return null |
| 64 | const sourcePlanRecord = getObject(record.sourcePlan) ?? record |
| 65 | const rawSkeleton = sourcePlanRecord.pageSkeleton |
| 66 | if (!Array.isArray(rawSkeleton)) return null |
| 67 | const pageSkeleton = rawSkeleton |
| 68 | .slice(0, MAX_SOURCE_PLAN_PAGES) |
| 69 | .map((item, index) => normalizeSourcePlanItem(item, index + 1)) |
| 70 | .filter((item): item is SourceDocumentPlan['pageSkeleton'][number] => Boolean(item)) |
| 71 | if (pageSkeleton.length === 0) return null |
| 72 | const confidence = |
| 73 | sourcePlanRecord.confidence === 'medium' || sourcePlanRecord.confidence === 'low' |
| 74 | ? sourcePlanRecord.confidence |
| 75 | : 'high' |
| 76 | return { |
| 77 | version: 1, |
| 78 | confidence, |
| 79 | sourceDocumentPath: readString(sourcePlanRecord.sourceDocumentPath) || undefined, |
| 80 | sourceDocumentName: readString(sourcePlanRecord.sourceDocumentName) || undefined, |
| 81 | pageSkeleton |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | export const sourcePlanFromSkeletonRows = (rows: unknown[]): SourceDocumentPlan | null => { |
| 86 | if (rows.length === 0) return null |
| 87 | const first = getObject(rows[0]) |
| 88 | if (!first) return null |
| 89 | const pageSkeleton = rows |
| 90 | .map((row, index) => { |
| 91 | const record = getObject(row) |
| 92 | if (!record) return null |
| 93 | return normalizeSourcePlanItem( |
| 94 | { |
| 95 | pageNumber: record.page_number ?? record.pageNumber, |
| 96 | title: record.title, |
| 97 | role: record.role, |
| 98 | sourceHeading: record.source_heading ?? record.sourceHeading, |
| 99 | headingLevel: record.heading_level ?? record.headingLevel, |
| 100 | lineStart: record.line_start ?? record.lineStart, |
| 101 | lineEnd: record.line_end ?? record.lineEnd, |
| 102 | reason: record.reason |
| 103 | }, |
| 104 | index + 1 |
| 105 | ) |
| 106 | }) |
| 107 | .filter((item): item is SourceDocumentPlan['pageSkeleton'][number] => Boolean(item)) |
| 108 | if (pageSkeleton.length === 0) return null |
| 109 | const confidence = |
| 110 | first.confidence === 'medium' || first.confidence === 'low' ? first.confidence : 'high' |
| 111 | return { |
| 112 | version: 1, |
| 113 | confidence, |
| 114 | sourceDocumentPath: |
| 115 | readString(first.source_document_path ?? first.sourceDocumentPath) || undefined, |
| 116 | sourceDocumentName: |
| 117 | readString(first.source_document_name ?? first.sourceDocumentName) || undefined, |
| 118 | pageSkeleton |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | export const userMessageRequestsOutlineRestructure = (value: string): boolean => |
| 123 | /重排|重组|重新规划|压缩|合并|拆分|删减|精简.*页|改成\s*\d+\s*页|做成\s*\d+\s*页|只做\s*\d+\s*页|rewrite.*outline|replan|restructure|compress|merge|split|make\s+it\s+\d+\s+(?:slides|pages)/i.test( |
| 124 | value |
| 125 | ) |
| 126 | |
| 127 | export const canUseSourcePlanDirectly = (args: { |
| 128 | sourcePlan: SourceDocumentPlan | null | undefined |
| 129 | totalPages: number |
| 130 | userMessage: string |
| 131 | }): boolean => |
| 132 | Boolean( |
| 133 | args.sourcePlan && |
| 134 | args.sourcePlan.confidence === 'high' && |
| 135 | args.sourcePlan.pageSkeleton.length === args.totalPages && |
| 136 | !userMessageRequestsOutlineRestructure(args.userMessage) |
| 137 | ) |
| 138 | |
| 139 | const inferLayoutIntentFromSkeletonTitle = ( |
| 140 | item: SourceDocumentPlan['pageSkeleton'][number] |
| 141 | ): OutlineItem['layoutIntent'] => { |
| 142 | const text = `${item.title}\n${item.sourceHeading}` |
| 143 | if (isSectionAgendaReason(item.reason)) return 'summary' |
| 144 | if (item.role === 'chapter-divider') return 'cover' |
| 145 | if (/指标|数据|收入|增长|下降|比例|金额|率|metric|revenue|growth|decline|kpi|%|\d/.test(text)) { |
| 146 | return 'data-focus' |
| 147 | } |
| 148 | if (/对比|比较|差异|竞品|comparison|versus|vs\.?/i.test(text)) return 'comparison' |
| 149 | if (/流程|步骤|路径|机制|workflow|process|step|how to/i.test(text)) return 'process' |
| 150 | if (/计划|阶段|路线|节奏|timeline|roadmap|phase|schedule/i.test(text)) return 'timeline' |
| 151 | if (/总结|结论|复盘|summary|conclusion|takeaway/i.test(text)) return 'summary' |
| 152 | return 'concept' |
| 153 | } |
| 154 | |
| 155 | export const mapSourcePlanToOutlineItems = (sourcePlan: SourceDocumentPlan): OutlineItem[] => |
| 156 | sourcePlan.pageSkeleton.map((item) => { |
| 157 | const inferredLayoutIntent = inferLayoutIntentFromSkeletonTitle(item) |
| 158 | const isSectionAgenda = isSectionAgendaReason(item.reason) |
| 159 | return { |
| 160 | title: item.title, |
| 161 | contentOutline: (isSectionAgenda |
| 162 | ? [SECTION_AGENDA_OUTLINE_MARKER, `Page purpose: ${item.reason}`] |
| 163 | : [ |
| 164 | `Source heading: ${item.sourceHeading}`, |
| 165 | `Source range: lines ${item.lineStart}-${item.lineEnd}`, |
| 166 | `Page role: ${item.role}`, |
| 167 | item.reason ? `Page purpose: ${item.reason}` : '' |
| 168 | ]) |
| 169 | .filter(Boolean) |
| 170 | .join('\n'), |
| 171 | layoutIntent: LAYOUT_INTENTS.has(inferredLayoutIntent || '') |
| 172 | ? inferredLayoutIntent |
| 173 | : 'concept' |
| 174 | } |
| 175 | }) |
| 176 |