返回 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 DocumentPlanAgendaItem,
6 type OutlineItem,
7 type PageReferenceContext,
8 type SourceDocumentPlan
9 } from '@shared/generation'
10
11 const MAX_SOURCE_PLAN_PAGES = 500
12 const LAYOUT_INTENTS = new Set([
13 'cover',
14 'data-focus',
15 'comparison',
16 'timeline',
17 'concept',
18 'process',
19 'summary',
20 'quote',
21 'image-focus'
22 ])
23
24 const getObject = (value: unknown): Record<string, unknown> | null =>
25 value && typeof value === 'object' && !Array.isArray(value)
26 ? (value as Record<string, unknown>)
27 : null
28
29 const readPositiveInt = (value: unknown): number | null => {
30 const n = Number(value)
31 return Number.isFinite(n) && n >= 1 ? Math.floor(n) : null
32 }
33
34 const readString = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
35
36 const readArray = (value: unknown): unknown[] => {
37 if (Array.isArray(value)) return value
38 if (typeof value !== 'string' || !value.trim()) return []
39 try {
40 const parsed = JSON.parse(value) as unknown
41 return Array.isArray(parsed) ? parsed : []
42 } catch {
43 return []
44 }
45 }
46
47 const normalizeAgendaItems = (value: unknown): DocumentPlanAgendaItem[] | undefined => {
48 const agendaItems = readArray(value)
49 .slice(0, MAX_SOURCE_PLAN_PAGES)
50 .map((item) => {
51 const record = getObject(item)
52 if (!record) return null
53 const title = readString(record.title)
54 const lineStart = readPositiveInt(record.lineStart ?? record.line_start)
55 return title && lineStart ? { title, lineStart } : null
56 })
57 .filter((item): item is DocumentPlanAgendaItem => Boolean(item))
58 return agendaItems.length > 0 ? agendaItems : undefined
59 }
60
61 const normalizeSourcePlanReason = (reason: string): string =>
62 reason && !isInternalDocumentPlanPageReason(reason) ? reason : ''
63
64 const normalizeSourcePlanItem = (value: unknown, fallbackPageNumber: number) => {
65 const record = getObject(value)
66 if (!record) return null
67 const pageNumber = readPositiveInt(record.pageNumber) ?? fallbackPageNumber
68 const title = readString(record.title) || `Slide ${pageNumber}`
69 const role = record.role === 'chapter-divider' ? 'chapter-divider' : 'content'
70 const sourceHeading = readString(record.sourceHeading)
71 const headingLevel = readPositiveInt(record.headingLevel) ?? 1
72 const lineStart = readPositiveInt(record.lineStart) ?? 1
73 const lineEnd = readPositiveInt(record.lineEnd) ?? lineStart
74 const reason = readString(record.reason)
75 const agendaItems = normalizeAgendaItems(record.agendaItems ?? record.agenda_items_json)
76 if (!sourceHeading || lineEnd < lineStart) return null
77 return {
78 pageNumber,
79 title,
80 role,
81 sourceHeading,
82 headingLevel,
83 lineStart,
84 lineEnd,
85 reason: normalizeSourcePlanReason(reason),
86 agendaItems
87 }
88 }
89
90 export const normalizeSourcePlan = (value: unknown): SourceDocumentPlan | null => {
91 const record = getObject(value)
92 if (!record) return null
93 const sourcePlanRecord = getObject(record.sourcePlan) ?? record
94 const rawSkeleton = sourcePlanRecord.pageSkeleton
95 if (!Array.isArray(rawSkeleton)) return null
96 const pageSkeleton = rawSkeleton
97 .slice(0, MAX_SOURCE_PLAN_PAGES)
98 .map((item, index) => normalizeSourcePlanItem(item, index + 1))
99 .filter((item): item is SourceDocumentPlan['pageSkeleton'][number] => Boolean(item))
100 if (pageSkeleton.length === 0) return null
101 const confidence =
102 sourcePlanRecord.confidence === 'medium' || sourcePlanRecord.confidence === 'low'
103 ? sourcePlanRecord.confidence
104 : 'high'
105 return {
106 version: 1,
107 confidence,
108 sourceDocumentPath: readString(sourcePlanRecord.sourceDocumentPath) || undefined,
109 sourceDocumentName: readString(sourcePlanRecord.sourceDocumentName) || undefined,
110 pageSkeleton
111 }
112 }
113
114 export const sourcePlanFromSkeletonRows = (rows: unknown[]): SourceDocumentPlan | null => {
115 if (rows.length === 0) return null
116 const first = getObject(rows[0])
117 if (!first) return null
118 const pageSkeleton = rows
119 .map((row, index) => {
120 const record = getObject(row)
121 if (!record) return null
122 return normalizeSourcePlanItem(
123 {
124 pageNumber: record.page_number ?? record.pageNumber,
125 title: record.title,
126 role: record.role,
127 sourceHeading: record.source_heading ?? record.sourceHeading,
128 headingLevel: record.heading_level ?? record.headingLevel,
129 lineStart: record.line_start ?? record.lineStart,
130 lineEnd: record.line_end ?? record.lineEnd,
131 reason: record.reason,
132 agendaItems: record.agenda_items_json ?? record.agendaItems
133 },
134 index + 1
135 )
136 })
137 .filter((item): item is SourceDocumentPlan['pageSkeleton'][number] => Boolean(item))
138 if (pageSkeleton.length === 0) return null
139 const confidence =
140 first.confidence === 'medium' || first.confidence === 'low' ? first.confidence : 'high'
141 return {
142 version: 1,
143 confidence,
144 sourceDocumentPath:
145 readString(first.source_document_path ?? first.sourceDocumentPath) || undefined,
146 sourceDocumentName:
147 readString(first.source_document_name ?? first.sourceDocumentName) || undefined,
148 pageSkeleton
149 }
150 }
151
152 export const userMessageRequestsOutlineRestructure = (value: string): boolean =>
153 /重排|重组|重新规划|压缩|合并|拆分|删减|精简.*页|改成\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(
154 value
155 )
156
157 export const canUseSourcePlanDirectly = (args: {
158 sourcePlan: SourceDocumentPlan | null | undefined
159 totalPages: number
160 userMessage: string
161 }): boolean =>
162 Boolean(
163 args.sourcePlan &&
164 args.sourcePlan.confidence === 'high' &&
165 args.sourcePlan.pageSkeleton.length === args.totalPages &&
166 !userMessageRequestsOutlineRestructure(args.userMessage)
167 )
168
169 export const resolvePageReferenceContext = (args: {
170 referenceDocumentPath?: string
171 sourcePlan: SourceDocumentPlan | null | undefined
172 pageNumber: number
173 }): PageReferenceContext | null => {
174 const referenceDocumentPath = args.referenceDocumentPath?.trim()
175 if (!referenceDocumentPath || !args.sourcePlan) return null
176 const sourceDocumentPath = args.sourcePlan.sourceDocumentPath?.trim()
177 if (sourceDocumentPath && sourceDocumentPath !== referenceDocumentPath) return null
178 const item = args.sourcePlan.pageSkeleton.find((candidate) => candidate.pageNumber === args.pageNumber)
179 if (!item) return null
180 const isSectionAgenda = Boolean(item.agendaItems?.length) || isSectionAgendaReason(item.reason)
181 return {
182 referenceDocumentPath,
183 sourceHeading: item.sourceHeading,
184 sourceRange: {
185 lineStart: item.lineStart,
186 lineEnd: item.lineEnd
187 },
188 agendaItems: item.agendaItems,
189 isSectionAgenda
190 }
191 }
192
193 const inferLayoutIntentFromSkeletonTitle = (
194 item: SourceDocumentPlan['pageSkeleton'][number]
195 ): OutlineItem['layoutIntent'] => {
196 const text = `${item.title}\n${item.sourceHeading}`
197 if (item.agendaItems?.length || isSectionAgendaReason(item.reason)) return 'summary'
198 if (item.role === 'chapter-divider') return 'cover'
199 if (/指标|数据|收入|增长|下降|比例|金额|率|metric|revenue|growth|decline|kpi|%|\d/.test(text)) {
200 return 'data-focus'
201 }
202 if (/对比|比较|差异|竞品|comparison|versus|vs\.?/i.test(text)) return 'comparison'
203 if (/流程|步骤|路径|机制|workflow|process|step|how to/i.test(text)) return 'process'
204 if (/计划|阶段|路线|节奏|timeline|roadmap|phase|schedule/i.test(text)) return 'timeline'
205 if (/总结|结论|复盘|summary|conclusion|takeaway/i.test(text)) return 'summary'
206 return 'concept'
207 }
208
209 export const mapSourcePlanToOutlineItems = (sourcePlan: SourceDocumentPlan): OutlineItem[] =>
210 sourcePlan.pageSkeleton.map((item) => {
211 const inferredLayoutIntent = inferLayoutIntentFromSkeletonTitle(item)
212 const isSectionAgenda = Boolean(item.agendaItems?.length) || isSectionAgendaReason(item.reason)
213 const agendaItemsMetadata = item.agendaItems?.length
214 ? `Agenda items JSON: ${JSON.stringify(item.agendaItems)}`
215 : ''
216 return {
217 title: item.title,
218 contentOutline: (isSectionAgenda
219 ? [
220 SECTION_AGENDA_OUTLINE_MARKER,
221 `Source heading: ${item.sourceHeading}`,
222 `Source range: lines ${item.lineStart}-${item.lineEnd}`,
223 agendaItemsMetadata,
224 !agendaItemsMetadata && item.reason ? `Page purpose: ${item.reason}` : ''
225 ]
226 : [
227 `Source heading: ${item.sourceHeading}`,
228 `Source range: lines ${item.lineStart}-${item.lineEnd}`,
229 `Page role: ${item.role}`,
230 item.reason ? `Page purpose: ${item.reason}` : ''
231 ])
232 .filter(Boolean)
233 .join('\n'),
234 layoutIntent: LAYOUT_INTENTS.has(inferredLayoutIntent || '')
235 ? inferredLayoutIntent
236 : 'concept'
237 }
238 })
239
239 lines TYPESCRIPT