返回 oh-my-ppt
document-plan-normalizer.ts
根目录 / src / main / io / document-plan-normalizer.ts
1 import type { ParsedDocumentPlanResult } from '@shared/generation'
2
3 const MAX_PAGE_COUNT = 500
4 const CHINESE_NUMERAL_MAP: Record<string, number> = {
5 零: 0,
6 一: 1,
7 二: 2,
8 两: 2,
9 三: 3,
10 四: 4,
11 五: 5,
12 六: 6,
13 七: 7,
14 八: 8,
15 九: 9
16 }
17
18 const getObject = (value: unknown): Record<string, unknown> | null =>
19 value && typeof value === 'object' && !Array.isArray(value)
20 ? (value as Record<string, unknown>)
21 : null
22
23 const isMeaningfulText = (value: string): boolean => value.trim().length > 0
24
25 const stringifyLooseValue = (value: unknown): string => {
26 if (typeof value === 'string') return value.trim()
27 if (typeof value === 'number' || typeof value === 'boolean') return String(value)
28 if (Array.isArray(value)) {
29 return value
30 .map((item) => stringifyLooseValue(item))
31 .filter(isMeaningfulText)
32 .join('\n')
33 }
34 const record = getObject(value)
35 if (record) {
36 return Object.entries(record)
37 .map(([key, item]) => {
38 const text = stringifyLooseValue(item)
39 return text ? `${key}:${text}` : ''
40 })
41 .filter(isMeaningfulText)
42 .join('\n')
43 }
44 return ''
45 }
46
47 const readFirstLooseString = (object: Record<string, unknown>, keys: string[]): string => {
48 for (const key of keys) {
49 const value = object[key]
50 const text = stringifyLooseValue(value)
51 if (text) return text
52 }
53 return ''
54 }
55
56 const unescapeLooseJsonString = (value: string): string =>
57 value
58 .replace(/\\n/g, '\n')
59 .replace(/\\r/g, '\n')
60 .replace(/\\t/g, '\t')
61 .replace(/\\"/g, '"')
62 .replace(/\\\\/g, '\\')
63 .trim()
64
65 const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
66
67 const extractLooseFieldFromText = (rawText: string, keys: string[]): string => {
68 for (const key of keys) {
69 const quotedPattern = new RegExp(
70 `["']${escapeRegExp(key)}["']\\s*[::]\\s*["']([\\s\\S]*?)(?=["']\\s*(?:,|}|\\n\\s*["'][^"']+["']\\s*[::]))`,
71 'i'
72 )
73 const quotedMatch = rawText.match(quotedPattern)
74 if (quotedMatch?.[1]?.trim()) return unescapeLooseJsonString(quotedMatch[1])
75
76 const linePattern = new RegExp(
77 `(?:^|\\n)\\s*["']?${escapeRegExp(key)}["']?\\s*[::]\\s*([\\s\\S]*?)(?=\\n\\s*["']?(?:${keys
78 .map(escapeRegExp)
79 .join('|')})["']?\\s*[::]|$)`,
80 'i'
81 )
82 const lineMatch = rawText.match(linePattern)
83 if (lineMatch?.[1]?.trim()) {
84 return unescapeLooseJsonString(lineMatch[1].replace(/[,}]\s*$/g, ''))
85 }
86 }
87 return ''
88 }
89
90 const stripLikelyJsonWrappers = (rawText: string): string =>
91 rawText
92 .replace(/```(?:json)?/gi, '')
93 .replace(/```/g, '')
94 .replace(/^\s*[{[]\s*/, '')
95 .replace(/\s*[}\]]\s*$/, '')
96 .trim()
97
98 const extractJsonBlock = (content: string): string => {
99 const trimmed = content.trim()
100 const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)
101 if (fenced?.[1]) return fenced[1].trim()
102 const firstBrace = trimmed.indexOf('{')
103 const lastBrace = trimmed.lastIndexOf('}')
104 if (firstBrace >= 0 && lastBrace > firstBrace) return trimmed.slice(firstBrace, lastBrace + 1)
105 return trimmed
106 }
107
108 const parseChinesePageNumber = (value: string): number | null => {
109 const text = value.trim()
110 if (!text) return null
111 if (/^\d+$/.test(text)) return Number.parseInt(text, 10)
112 if (text === '十') return 10
113 if (text.startsWith('十')) {
114 const ones = CHINESE_NUMERAL_MAP[text.slice(1)]
115 return ones !== undefined ? 10 + ones : null
116 }
117 if (text.includes('十')) {
118 const [tensRaw, onesRaw = ''] = text.split('十')
119 const tens = CHINESE_NUMERAL_MAP[tensRaw]
120 const ones = onesRaw ? CHINESE_NUMERAL_MAP[onesRaw] : 0
121 return tens !== undefined && ones !== undefined ? tens * 10 + ones : null
122 }
123 return CHINESE_NUMERAL_MAP[text] ?? null
124 }
125
126 const extractNumberedSectionCount = (text: string, headingPattern: RegExp): number => {
127 const lines = text.split('\n')
128 const startIndex = lines.findIndex((line) => headingPattern.test(line))
129 if (startIndex < 0) return 0
130 let count = 0
131 let lastNumber = 0
132 for (const line of lines.slice(startIndex + 1)) {
133 const trimmed = line.trim()
134 if (!trimmed) continue
135 if (/^(每页要点|必须保留|风格|表达|注意事项|受众|核心观点|演示目标)\s*[::]/.test(trimmed))
136 break
137 const match = trimmed.match(/^(\d{1,2})\s*[.、.)]\s*\S+/)
138 if (!match) {
139 if (count > 0 && /^[^\d第]/.test(trimmed)) break
140 continue
141 }
142 const n = Number.parseInt(match[1], 10)
143 if (Number.isFinite(n) && n >= 1 && n <= MAX_PAGE_COUNT) {
144 lastNumber = Math.max(lastNumber, n)
145 count += 1
146 }
147 }
148 return Math.max(count, lastNumber)
149 }
150
151 export const extractImpliedPageCount = (text: string): number => {
152 const pageNumbers = Array.from(text.matchAll(/第\s*([一二两三四五六七八九十\d]{1,3})\s*页/g))
153 .map((match) => parseChinesePageNumber(match[1] || ''))
154 .filter((value): value is number => Boolean(value && value >= 1 && value <= MAX_PAGE_COUNT))
155 const englishPageNumbers = Array.from(text.matchAll(/\bPage\s+(\d{1,2})\s*[::.\-]/gi))
156 .map((match) => Number.parseInt(match[1] || '', 10))
157 .filter((value) => Number.isFinite(value) && value >= 1 && value <= MAX_PAGE_COUNT)
158 const maxPageNumber =
159 pageNumbers.length > 0 || englishPageNumbers.length > 0
160 ? Math.max(...pageNumbers, ...englishPageNumbers)
161 : 0
162 const outlineCount = extractNumberedSectionCount(text, /建议大纲|大纲|目录/)
163 const pagePointCount = extractNumberedSectionCount(text, /每页要点|页面要点|页级要点/)
164 return Math.min(MAX_PAGE_COUNT, Math.max(maxPageNumber, outlineCount, pagePointCount, 0))
165 }
166
167 export const normalizeGeneratedPlan = (
168 rawText: string,
169 fallback: {
170 topic: string
171 pageCount: number | null
172 briefText: string
173 }
174 ): Pick<ParsedDocumentPlanResult, 'topic' | 'pageCount' | 'briefText'> => {
175 const parsed = (() => {
176 try {
177 return JSON.parse(extractJsonBlock(rawText)) as unknown
178 } catch {
179 return null
180 }
181 })()
182 const object =
183 parsed && typeof parsed === 'object' && !Array.isArray(parsed)
184 ? (parsed as Record<string, unknown>)
185 : {}
186
187 const topicKeys = ['topic', 'title', '主题', '标题']
188 const briefKeys = [
189 'briefText',
190 'brief_text',
191 'brief',
192 'description',
193 'detail',
194 'detailedDescription',
195 'outline',
196 'summary',
197 'content',
198 'plan',
199 '详细描述',
200 '描述',
201 '大纲',
202 '建议大纲'
203 ]
204 const pageCountKeys = ['pageCount', 'page_count', 'pages', 'totalPages', '页数']
205
206 const topic =
207 readFirstLooseString(object, topicKeys) ||
208 extractLooseFieldFromText(rawText, topicKeys) ||
209 fallback.topic ||
210 ''
211 const rawPageCountValue =
212 pageCountKeys.map((key) => object[key]).find((value) => value !== undefined) ??
213 extractLooseFieldFromText(rawText, pageCountKeys)
214 const rawPageCount = Number(rawPageCountValue)
215 const hasExplicitPageCount = Number.isFinite(rawPageCount)
216 const normalizedPageCount = hasExplicitPageCount
217 ? Math.min(MAX_PAGE_COUNT, Math.max(1, Math.round(rawPageCount)))
218 : fallback.pageCount || 5
219 const parsedHasBriefKey = Object.keys(object).some((key) => briefKeys.includes(key))
220 const looseBriefText = parsedHasBriefKey
221 ? (readFirstLooseString(object, briefKeys) ?? '')
222 : readFirstLooseString(object, briefKeys) ||
223 extractLooseFieldFromText(rawText, briefKeys) ||
224 fallback.briefText ||
225 stripLikelyJsonWrappers(rawText)
226 const briefText = looseBriefText.trim()
227 const impliedPageCount = extractImpliedPageCount(`${briefText}\n${rawText}`)
228 const pageCount =
229 hasExplicitPageCount && !(fallback.pageCount === null && normalizedPageCount <= 1 && impliedPageCount >= 2)
230 ? normalizedPageCount
231 : impliedPageCount >= 2
232 ? impliedPageCount
233 : normalizedPageCount
234
235 if (!topic.trim()) throw new Error('文档解析完成,但模型未返回 topic')
236 if (!briefText) throw new Error('文档解析完成,但模型未返回 briefText')
237
238 return {
239 topic: topic.trim(),
240 pageCount,
241 briefText
242 }
243 }
244
244 lines TYPESCRIPT