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