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