返回 oh-my-ppt
content-expansion.test.ts
根目录 / tests / unit / prompt / content-expansion.test.ts
1 import { describe, expect, it } from 'vitest'
2 import fs from 'fs'
3 import path from 'path'
4 import {
5 buildDeckAgentSystemPrompt,
6 buildSinglePageGenerationPrompt
7 } from '../../../src/main/agent-runtime/prompt'
8 import type { SessionDeckGenerationContext } from '../../../src/main/agent-runtime/agent'
9 import { resolveSlideSize } from '../../../src/shared/slide-size'
10
11 const readSource = (relativePath: string): string =>
12 fs.readFileSync(path.join(process.cwd(), relativePath), 'utf-8')
13
14 const baseContext: SessionDeckGenerationContext = {
15 sessionId: 'session-1',
16 projectDir: '/tmp/project',
17 indexPath: '/tmp/project/index.html',
18 pageFileMap: { 'page-1': '/tmp/project/page-1.html' },
19 topic: 'Quarterly report',
20 deckTitle: 'Quarterly report',
21 styleId: 'test-style',
22 styleSkillPrompt: 'Use a clean business style.',
23 userMessage: 'Create a quarterly report.',
24 outlineTitles: ['Overview'],
25 outlineItems: [{ title: 'Overview', contentOutline: 'Summarize the quarter.' }],
26 slideSize: resolveSlideSize({ id: 'wide-16-9' }),
27 appLocale: 'en'
28 }
29
30 describe('content expansion rules — always-on, not source-gated', () => {
31 it('keeps a selected layout master as a flexible style-aware composition', () => {
32 const pagePrompt = buildSinglePageGenerationPrompt({
33 topic: 'Quarterly report',
34 deckTitle: 'Quarterly report',
35 pageId: 'page-1',
36 pageNumber: 1,
37 pageTitle: 'Overview',
38 pageOutline: 'Summarize the quarter.',
39 slideSize: baseContext.slideSize,
40 layoutIntent: 'data-focus',
41 layoutId: 'data-chart-side',
42 layoutPrompt:
43 'Selected layout master: Chart with takeaway (data-chart-side).\nTreat this as a flexible information architecture, not a pixel-for-pixel template. Keep the current style contract authoritative for visual language.'
44 })
45
46 const deckPrompt = buildDeckAgentSystemPrompt('test-style', {
47 ...baseContext,
48 outlineItems: [
49 {
50 title: 'Overview',
51 contentOutline: 'Summarize the quarter.',
52 layoutIntent: 'data-focus',
53 layoutId: 'data-chart-side',
54 layoutPrompt:
55 'Selected layout master: Chart with takeaway (data-chart-side).\nTreat this as a flexible information architecture, not a pixel-for-pixel template. Keep the current style contract authoritative for visual language.'
56 }
57 ]
58 })
59
60 expect(pagePrompt).toContain('Selected layout master: Chart with takeaway')
61 expect(pagePrompt).toContain('flexible information architecture')
62 expect(deckPrompt).toContain('Selected layout master: Chart with takeaway')
63 })
64
65 it('scenario expansion rules expand only when the page is truly thin', () => {
66 const scenario = readSource('src/main/agent-runtime/prompt/composers/canvas-scenario.ts')
67
68 // Expansion is conditional: enough content means choose, group, and budget —
69 // not more modules. This guards against dense source pages overflowing.
70 expect(scenario).toContain('export function buildCanvasScenarioExpansionRules')
71 expect(scenario).toContain('内容丰富与优化规则')
72 expect(scenario).toContain('够了就压缩')
73 expect(scenario).toContain('禁止捏造')
74 expect(scenario).toContain('收在当前画布内')
75 expect(scenario).toContain('演示页的“够”')
76 expect(scenario).toContain('竖屏的“够”')
77 expect(scenario).toContain('小红书页的“够”')
78 })
79
80 it('density control is single-sourced in canvas constraints, not duplicated in scenario expansion rules', () => {
81 const shared = readSource('src/main/agent-runtime/prompt/composers/shared.ts')
82 const scenario = readSource('src/main/agent-runtime/prompt/composers/canvas-scenario.ts')
83 const expansionStart = scenario.indexOf('export function buildCanvasScenarioExpansionRules')
84 const expansionBlock = scenario.slice(
85 expansionStart,
86 scenario.indexOf('export function buildCanvasScenarioDeliveryGuard', expansionStart)
87 )
88 const canvasStart = shared.indexOf('export function buildCanvasConstraints')
89 const canvasBlock = shared.slice(
90 canvasStart,
91 shared.indexOf('export function buildLayoutCollisionRules', canvasStart)
92 )
93
94 // Density control lives once, in the always-on canvas block that reaches
95 // generation AND edit. Scenario expansion only owns the expansion trigger
96 // and guardrails, so it must not drift into layout-specific recipes.
97 expect(canvasBlock).toContain('密度由内容决定')
98 expect(expansionBlock).not.toContain('扩展不是堆卡片')
99 expect(expansionBlock).toContain('偏薄')
100 })
101
102 it('is imported by the real deck-agent entry and single-page generation', () => {
103 const deckSystem = readSource('src/main/agent-runtime/prompt/composers/deck-system.ts')
104 const generationUser = readSource('src/main/agent-runtime/prompt/composers/generation-user.ts')
105
106 // The deck path runs through buildDeckAgentSystemPrompt (called in agent.ts).
107 // Wire the rule where it actually ships.
108 expect(deckSystem).toContain('buildCanvasScenarioExpansionRules')
109 expect(generationUser).toContain('buildCanvasScenarioExpansionRules')
110 })
111
112 it('the dead deck helper is gone (deck runs through buildDeckAgentSystemPrompt, not a never-called helper)', () => {
113 const generationUser = readSource('src/main/agent-runtime/prompt/composers/generation-user.ts')
114 expect(generationUser).not.toContain('buildDeckGenerationPrompt')
115 expect(generationUser).not.toContain('buildOutlinePageList')
116 })
117
118 it('deck agent wires it into the always-on system prompt (after the source-document block)', () => {
119 const promptWithoutSources = buildDeckAgentSystemPrompt('test-style', baseContext)
120 const promptWithSources = buildDeckAgentSystemPrompt('test-style', {
121 ...baseContext,
122 sourceDocumentPaths: ['/docs/source.md']
123 })
124
125 const expansionMarker = '## 内容丰富与优化规则(演示页)'
126 expect(promptWithoutSources).toContain(expansionMarker)
127 expect(promptWithSources).toContain(expansionMarker)
128 expect(promptWithSources.indexOf('## Source documents')).toBeLessThan(
129 promptWithSources.indexOf(expansionMarker)
130 )
131 })
132
133 it('single-page generation wires it into the always-on return, not the source-gated block', () => {
134 const generationUser = readSource('src/main/agent-runtime/prompt/composers/generation-user.ts')
135 const singlePageSource = generationUser.slice(
136 generationUser.indexOf('export function buildSinglePageGenerationPrompt')
137 )
138
139 // Present in the main return array (after retryInstructions), not inside the
140 // sourceDocumentInstructions ternary that only fires with source documents.
141 const afterRetry = singlePageSource.slice(singlePageSource.indexOf('...retryInstructions'))
142 expect(afterRetry).toContain('buildCanvasScenarioExpansionRules(args.slideSize)')
143 })
144
145 it('generation prompts keep page form in scenario rules and content enrichment in scenario expansion rules', () => {
146 const deckPrompt = buildDeckAgentSystemPrompt('test-style', {
147 ...baseContext,
148 animationPreferences: { ids: ['fade'] }
149 })
150 const pagePrompt = buildSinglePageGenerationPrompt({
151 topic: 'Quarterly report',
152 deckTitle: 'Quarterly report',
153 pageId: 'page-1',
154 pageNumber: 1,
155 pageTitle: 'Overview',
156 pageOutline: 'Summarize the quarter.',
157 slideSize: baseContext.slideSize
158 })
159
160 expect(pagePrompt).toContain('Required content enrichment decision before writing')
161 expect(pagePrompt).toContain('First use the Canvas scenario rules to decide the page form')
162 expect(pagePrompt).toContain(
163 'scenario expansion rules only to decide whether the content itself needs enrichment'
164 )
165 expect(pagePrompt).toContain('the page is thin: enrich the warranted structure')
166 expect(pagePrompt).toContain('animation is downstream only')
167 expect(pagePrompt).toContain(
168 'must follow the current canvas scenario, source grounding, and warranted content enrichment'
169 )
170
171 expect(deckPrompt).toContain('Animation preferences for page writing only')
172 expect(deckPrompt).toContain('Animation is downstream only')
173 expect(deckPrompt).toContain('Never reduce, skip, or reshape warranted content enrichment')
174 expect(deckPrompt).toContain('写 HTML 前判断')
175 expect(deckPrompt.indexOf('写 HTML 前判断')).toBeLessThan(
176 deckPrompt.indexOf('Animation preferences for page writing only')
177 )
178 })
179
180 it('section agenda page prompts do not request source document reading', () => {
181 const pagePrompt = buildSinglePageGenerationPrompt({
182 topic: 'AI动漫报告',
183 deckTitle: 'AI动漫报告',
184 pageId: 'page-2',
185 pageNumber: 2,
186 pageTitle: '二、技术参数与技术效率明细',
187 pageOutline: [
188 'Page role: section-agenda',
189 'Page purpose: 章节目录页:概览本章下的子主题,包括:2.1 主流AI动漫工具性能对比、2.2 训练数据规模、2.3 效率实证。'
190 ].join('\n'),
191 slideSize: baseContext.slideSize,
192 sourceDocumentPaths: ['/docs/source.md'],
193 referenceDocumentSnippets: '[片段 1] /docs/source.md#L18-L50\n内容:should not appear'
194 })
195
196 expect(pagePrompt).toContain('Section agenda page requirements')
197 expect(pagePrompt).toContain('Use only the child topic names already listed')
198 expect(pagePrompt).not.toContain('Source document requirements')
199 expect(pagePrompt).not.toContain('Range-bound source reading')
200 expect(pagePrompt).not.toContain('参考文档检索片段')
201 expect(pagePrompt).not.toContain('should not appear')
202 })
203
204 it('section agenda single-page system prompts ignore source document paths', () => {
205 const deckPrompt = buildDeckAgentSystemPrompt('test-style', {
206 ...baseContext,
207 sourceDocumentPaths: ['/docs/source.md'],
208 selectedPageId: 'page-1',
209 selectedPageNumber: 1,
210 outlineTitles: ['二、技术参数与技术效率明细'],
211 outlineItems: [
212 {
213 title: '二、技术参数与技术效率明细',
214 contentOutline: [
215 'Page role: section-agenda',
216 'Page purpose: 章节目录页:概览本章下的子主题,包括:2.1 主流AI动漫工具性能对比、2.2 训练数据规模、2.3 效率实证。'
217 ].join('\n'),
218 layoutIntent: 'summary'
219 }
220 ]
221 })
222
223 expect(deckPrompt).not.toContain('## Source documents')
224 expect(deckPrompt).not.toContain('source-reading skill')
225 expect(deckPrompt).not.toContain('/docs/source.md')
226 })
227
228 it('scenario content rules own the form guidance while scenario expansion owns enrichment', () => {
229 const scenario = readSource('src/main/agent-runtime/prompt/composers/canvas-scenario.ts')
230 const deckSystem = readSource('src/main/agent-runtime/prompt/composers/deck-system.ts')
231 const generationUser = readSource('src/main/agent-runtime/prompt/composers/generation-user.ts')
232
233 expect(scenario).toContain('export function buildCanvasScenarioContentRules')
234 expect(scenario).toContain('3 秒主旨')
235 expect(scenario).toContain('PPT 是演讲辅助')
236 expect(scenario).toContain('移动端竖屏')
237 expect(scenario).toContain('小红书图文笔记')
238 expect(scenario).toContain('一个焦点')
239 expect(scenario).toContain('构图平衡')
240
241 // Both real generation entries import and foreground it (DRY — one source).
242 expect(deckSystem).toContain('buildCanvasScenarioContentRules')
243 expect(generationUser).toContain('buildCanvasScenarioContentRules')
244
245 // Form guidance and source-grounded content enrichment live ONLY in the
246 // rewrite-capable edit paths (single-page + deck). Selector (element-level)
247 // and container edits must NOT carry whole-page signals —
248 // that would violate their narrow scope. Slice each edit function's body and
249 // assert the boundary precisely so a future mis-wire is caught.
250 const editSystem = readSource('src/main/agent-runtime/prompt/composers/edit-system.ts')
251 const containerEdit = editSystem.slice(
252 editSystem.indexOf('function buildContainerEditPrompt('),
253 editSystem.indexOf('function buildSelectorEditPrompt(')
254 )
255 const selectorEdit = editSystem.slice(
256 editSystem.indexOf('function buildSelectorEditPrompt('),
257 editSystem.indexOf('function buildSinglePageEditPrompt(')
258 )
259 const singlePageEdit = editSystem.slice(
260 editSystem.indexOf('function buildSinglePageEditPrompt('),
261 editSystem.indexOf('function buildDeckEditPrompt(')
262 )
263 const deckEdit = editSystem.slice(editSystem.indexOf('function buildDeckEditPrompt('))
264
265 expect(singlePageEdit).toContain('buildCanvasScenarioContentRules')
266 expect(deckEdit).toContain('buildCanvasScenarioContentRules')
267 expect(selectorEdit).not.toContain('buildCanvasScenarioContentRules')
268 expect(containerEdit).not.toContain('buildCanvasScenarioContentRules')
269
270 expect(singlePageEdit).toContain('buildCanvasScenarioExpansionRules')
271 expect(deckEdit).toContain('buildCanvasScenarioExpansionRules')
272 expect(selectorEdit).not.toContain('buildCanvasScenarioExpansionRules')
273 expect(containerEdit).not.toContain('buildCanvasScenarioExpansionRules')
274
275 // SOURCE_GROUNDED_EXPANSION_RULES ("enrich the slide") is gated to the rewrite
276 // paths via includeExpansion; selector/container must not enable it.
277 expect(singlePageEdit).toContain('includeExpansion: true')
278 expect(deckEdit).toContain('includeExpansion: true')
279 expect(selectorEdit).not.toContain('includeExpansion: true')
280 expect(containerEdit).not.toContain('includeExpansion: true')
281
282 // The old checklist-mirroring directive is gone (it contradicted the thesis-first rule).
283 expect(deckSystem).not.toContain(
284 'Fill each corresponding page strictly according to the content points'
285 )
286 })
287 })
288
288 lines TYPESCRIPT