返回 oh-my-ppt
source-plan.ts
根目录 / src / main / generation / source-plan.ts
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
176 lines TYPESCRIPT