Files
lindyson-ai/apps/api/tests/promptWizardCore.test.ts
Juno ChowandClaude Fable 5.1 f665082dad chore: 建立产品仓库,完成开源前的敏感信息清理
前端(Vite + React)、API/worker(Fastify + BullMQ)、api-contracts 契约包、
迁移脚本、测试与自托管 docker-compose 示例。

开源准备:运维脚本与机器信息已拆到私有仓库;公司名、备案号、隐私邮箱改读
publicCompliance.local.json(仓库只保留占位模板);站点地址与客服二维码改为
VITE_ 环境变量;fixtures 中的真实用户标识已匿名化;三个 package.json 标注
Apache-2.0。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-16 02:10:46 +08:00

1316 lines
45 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import {
promptWizardVideoConfigSchema,
type PromptWizardConceptPreview,
type PromptWizardEvidenceGraph,
type PromptWizardCreativeBrief,
type PromptWizardPlan,
type PromptWizardProductProfile,
type PromptWizardVideoConfig,
} from '@lindyson/api-contracts';
import {
PROMPT_WIZARD_MAX_JACCARD,
PROMPT_WIZARD_MIN_SIMHASH_DISTANCE,
creativeBody,
simHashHammingDistance,
trigramJaccard,
validatePromptWizardPlanDiversity,
validatePromptWizardPlanHistory,
} from '../src/domain/promptWizardDiversity.js';
import {
LEGACY_PROMPT_WIZARD_PROHIBITION,
buildPromptWizardConstraintBlock,
normalizePromptWizardProductionPrompt,
promptWizardBeatRoles,
promptWizardTimeline,
renderPromptWizardPlan as renderPromptWizardPlanV4,
type PromptWizardPlanDraft,
} from '../src/domain/promptWizardPrompt.js';
import { buildPromptWizardEvidenceGraph } from '../src/domain/promptWizardEvidenceGraph.js';
import { conceptPreviewToAssignment } from '../src/domain/promptWizardConceptDirector.js';
import {
allocatePromptWizardStrategies,
countDifferentStrategyAxes,
strategySignature,
type PromptWizardStrategy,
} from '../src/domain/promptWizardStrategy.js';
import { PROMPT_WIZARD_PLAN_COUNT } from '../src/domain/promptWizardPlanCount.js';
const PROFILE: PromptWizardProductProfile = {
productName: '折叠式降噪耳机',
category: '头戴式耳机',
colors: '黑色',
materials: '哑光塑料与软皮耳罩',
structure: '可折叠头梁、椭圆耳罩',
logo: '右侧耳罩有可见 Logo',
usage: '佩戴于双耳并按下电源键',
suggestedAudience: '通勤用户',
suggestedScenarios: '通勤、办公与旅行',
visibleFacts: ['黑色外观', '头梁可以折叠'],
needsConfirmation: ['续航时长需由用户确认'],
userConfirmedClaims: '',
consistencyConstraints: '所有镜头保持颜色、材质和耳罩结构一致',
};
const BRIEF: PromptWizardCreativeBrief = {
targetLanguage: '英语',
direction: 'ugc',
marketingGoal: 'conversion',
creativeIntensity: 'balanced',
confirmedOffer: '',
};
const CONFIG: PromptWizardVideoConfig = {
model: 'seedance-2.0',
aspectRatio: '9:16',
resolution: '720p',
durationSeconds: 15,
};
type RenderPromptWizardPlanInput = Parameters<typeof renderPromptWizardPlanV4>[0];
type RenderPromptWizardPlanTestInput = Omit<
RenderPromptWizardPlanInput,
'conceptAssignment' | 'conceptPreview' | 'evidenceGraph'
> & Partial<Pick<
RenderPromptWizardPlanInput,
'conceptAssignment' | 'conceptPreview' | 'evidenceGraph'
>>;
/**
* 领域测试的旧夹具多数只关心渲染边界。统一在这里补全 3.0
* 权威上下文,避免测试绕过生产中必须的 Concept Set 和 EvidenceGraph。
*/
function renderPromptWizardPlan(input: RenderPromptWizardPlanTestInput) {
const evidenceGraph = input.evidenceGraph ?? buildPromptWizardEvidenceGraph(
input.profile,
input.imageCount,
);
const conceptPreview = input.conceptPreview ?? makeConceptPreview(
input.id,
input.strategy,
evidenceGraph,
);
const conceptAssignment = input.conceptAssignment
?? conceptPreviewToAssignment(conceptPreview, 0);
return renderPromptWizardPlanV4({
...input,
evidenceGraph,
conceptPreview,
conceptAssignment,
});
}
function makeConceptPreview(
id: string,
strategy: PromptWizardStrategy,
evidenceGraph: PromptWizardEvidenceGraph,
): PromptWizardConceptPreview {
return {
id,
role: 'reliable-conversion',
title: '通勤真实操作路线',
firstSecondEvent: '人物从包中拿出耳机并立即展开头梁',
viewerQuestion: '这副耳机如何更顺手地融入通勤动作?',
purchaseReason: '可折叠头梁和清楚结构方便真实展示',
tension: '拥挤通勤中取放和佩戴动作容易被打断',
productAction: '人物展开头梁后将耳机佩戴到双耳',
visibleResult: '头梁和耳罩结构在连续动作中清楚可见',
evidenceIds: [evidenceGraph.reliableEvidenceIds[0] ?? 'visible:1'],
scene: '早高峰地铁站台',
perspective: '手机贴身跟拍',
cameraGrammar: '手部近景跟随动作推近产品细节',
ctaCallback: '如果你也在意通勤中的取放节奏,可以再看看这些结构细节。',
requiredAssets: ['产品参考图片'],
difficulty: 'low',
risk: 'low',
direction: 'ugc',
strategy,
productionLoad: {
locationCount: 1,
personCount: 1,
majorActionCount: 1,
propCount: 0,
cameraMoveCount: 1,
hasMagicTransition: false,
},
rank: 1,
};
}
describe('提示词向导视频配置契约', () => {
it.each(['seedance-2.0-fast', 'seedance-2.0-mini'] as const)(
'%s 禁止使用 1080p',
model => {
const result = promptWizardVideoConfigSchema.safeParse({
...CONFIG,
model,
resolution: '1080p',
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toEqual(expect.arrayContaining([
expect.objectContaining({
path: ['resolution'],
message: 'Seedance Fast 和 Mini 不支持 1080p',
}),
]));
}
},
);
it('普通版允许 1080p,且配置边界不接受参考模式字段', () => {
expect(promptWizardVideoConfigSchema.safeParse({
...CONFIG,
resolution: '1080p',
}).success).toBe(true);
expect(promptWizardVideoConfigSchema.safeParse({
...CONFIG,
mode: 'first-last',
}).success).toBe(false);
});
});
describe('提示词向导时间轴与渲染边界', () => {
it('为 5、10、15 秒生成连续且完整覆盖时长的固定时间轴', () => {
expect(promptWizardTimeline(5)).toEqual([
[0, 1.2],
[1.2, 3.5],
[3.5, 5],
]);
expect(promptWizardTimeline(10)).toEqual([
[0, 2],
[2, 5],
[5, 8],
[8, 10],
]);
expect(promptWizardTimeline(15)).toEqual([
[0, 3],
[3, 7],
[7, 12],
[12, 15],
]);
});
it.each([5, 10, 15] as const)('%s 秒提示词按固定时间轴渲染且不超过 3800 字符', durationSeconds => {
const strategy = allocatePromptWizardStrategies({
runId: `timeline-${durationSeconds}`,
ordinal: 0,
previousSignatures: [],
})[0]!;
const draft = makeDraft(promptWizardTimeline(durationSeconds).length);
const plan = renderPromptWizardPlan({
id: `00000000-0000-4000-8000-0000000000${String(durationSeconds).padStart(2, '0')}`,
draft,
strategy,
profile: PROFILE,
brief: BRIEF,
config: { ...CONFIG, durationSeconds },
imageCount: 1,
});
const timeline = promptWizardTimeline(durationSeconds);
for (const [start, end] of timeline) {
expect(plan.prompt).toContain(`【${formatSecond(start)}-${formatSecond(end)}s|`);
}
expect(plan.deliveryVersion).toBe(4);
expect(plan.shots.map(shot => shot.role)).toEqual(promptWizardBeatRoles(durationSeconds));
expect(plan.prompt.length).toBeLessThanOrEqual(3_800);
});
it('镜头数量错误将整个 segments 数组作为真实可修复路径', () => {
const strategy = allocatePromptWizardStrategies({
runId: 'timeline-repair-path',
ordinal: 0,
previousSignatures: [],
})[0]!;
expect(() => renderPromptWizardPlan({
id: '00000000-0000-4000-8000-000000000084',
draft: makeDraft(3),
strategy,
profile: PROFILE,
brief: BRIEF,
config: CONFIG,
imageCount: 1,
})).toThrow(expect.objectContaining({
code: 'PROMPT_WIZARD_TIMELINE_INVALID',
fieldPaths: ['segments'],
}));
});
it('最终提示词超过 3800 字符时整套方案失败', () => {
const strategy = allocatePromptWizardStrategies({
runId: 'over-limit',
ordinal: 0,
previousSignatures: [],
})[0]!;
expect(() => renderPromptWizardPlan({
id: '00000000-0000-4000-8000-000000000099',
draft: makeDraft(4),
strategy,
profile: {
...PROFILE,
userConfirmedClaims: '用户确认事实'.repeat(350),
consistencyConstraints: '产品一致性要求'.repeat(350),
},
brief: BRIEF,
config: CONFIG,
imageCount: 1,
})).toThrow(expect.objectContaining({
code: 'PROMPT_WIZARD_PROMPT_TOO_LONG',
fieldPaths: ['prompt'],
}));
});
it('Draft 镜头正文导致超长时只允许压缩 segments,不把最终 prompt 交给 Provider', () => {
const strategy = allocatePromptWizardStrategies({
runId: 'over-limit-draft-segments',
ordinal: 0,
previousSignatures: [],
})[0]!;
const draft = makeDraft(4);
draft.segments = draft.segments.map(segment => ({
...segment,
location: '真实室内环境'.repeat(25),
subject: '人物与参考产品'.repeat(22),
action: '连续展示真实操作过程'.repeat(25),
cameraMovement: '手持贴身跟拍'.repeat(25),
lightingAndRealism: '自然光线与手机实拍质感'.repeat(18),
// 本用例只验证镜头正文导致的生产稿超长;口播保持可在镜头时长内说完,
// 避免被 3.0 口播时长门禁更早拦截。
voiceover: segment.voiceover,
voiceoverZh: '自然展示产品操作。'.repeat(12),
foley: '真实环境声与操作声'.repeat(18),
editingCue: '跟随动作节点自然切换'.repeat(16),
}));
expect(() => renderPromptWizardPlan({
id: '00000000-0000-4000-8000-000000000083',
draft,
strategy,
profile: PROFILE,
brief: BRIEF,
config: CONFIG,
imageCount: 1,
})).toThrow(expect.objectContaining({
code: 'PROMPT_WIZARD_PROMPT_TOO_LONG',
fieldPaths: ['segments'],
}));
});
it('V4 生产提示词不包含视频规格、译文、花字、产品名称/品类行或历史禁止事项尾段', () => {
const strategy = allocatePromptWizardStrategies({
runId: 'production-copy-boundary',
ordinal: 0,
previousSignatures: [],
})[0]!;
const draft = makeDraft(4);
draft.segments[0] = {
...draft.segments[0]!,
voiceover: 'Hear every detail.',
voiceoverZh: '听见每一个细节。',
foley: '清楚记录耳机转轴的真实开合声',
productionNote: '后期检查小尺寸 Logo 的边缘是否完整',
};
const plan = renderPromptWizardPlan({
id: '00000000-0000-4000-8000-000000000091',
draft,
strategy,
profile: PROFILE,
brief: BRIEF,
config: CONFIG,
imageCount: 1,
});
expect(plan.prompt).toContain('Hear every detail.');
expect(plan.prompt).toContain('声音:清楚记录耳机转轴的真实开合声');
expect(plan.prompt).not.toContain('听见每一个细节。');
expect(plan.prompt).not.toContain('后期检查小尺寸 Logo 的边缘是否完整');
expect(plan.prompt).not.toContain(draft.planningReview.realPainPoint);
expect(plan.prompt).not.toContain('【创意主线】');
expect(plan.prompt).not.toContain('【视频规格】');
expect(plan.prompt).not.toMatch(/^产品名称:/mu);
expect(plan.prompt).not.toMatch(/^品类:/mu);
expect(plan.prompt).not.toContain('画面文字');
expect(plan.prompt).not.toContain('后期花字');
expect(plan.prompt).not.toMatch(/^颜色:/mu);
expect(plan.prompt).not.toContain('【禁止事项】');
expect(plan.deliveryVersion).toBe(4);
expect(plan.shots[0]).toEqual(expect.objectContaining({
startSeconds: 0,
endSeconds: 3,
voiceover: 'Hear every detail.',
voiceoverZh: '听见每一个细节。',
}));
expect('translationSegments' in plan).toBe(false);
expect('alternativeHooks' in plan).toBe(false);
});
it('口播译文不计入 3800 字符生产稿限制,普通话方案不重复返回译文', () => {
const strategy = allocatePromptWizardStrategies({
runId: 'translation-length-boundary',
ordinal: 0,
previousSignatures: [],
})[0]!;
const translatedDraft = makeDraft(4);
translatedDraft.segments = translatedDraft.segments.map(segment => ({
...segment,
voiceoverZh: '译'.repeat(300),
}));
const translatedPlan = renderPromptWizardPlan({
id: '00000000-0000-4000-8000-000000000092',
draft: translatedDraft,
strategy,
profile: PROFILE,
brief: BRIEF,
config: CONFIG,
imageCount: 1,
});
expect(translatedPlan.prompt.length).toBeLessThanOrEqual(3_800);
expect(translatedPlan.prompt).not.toContain('译'.repeat(20));
expect(translatedPlan.shots).toHaveLength(4);
expect(translatedPlan.shots.every(shot => shot.voiceoverZh.length === 300)).toBe(true);
const mandarinDraft = makeDraft(4);
mandarinDraft.segments = mandarinDraft.segments.map(segment => ({
...segment,
voiceover: segment.voiceoverZh,
voiceoverZh: '',
}));
const mandarinPlan = renderPromptWizardPlan({
id: '00000000-0000-4000-8000-000000000093',
draft: mandarinDraft,
strategy,
profile: PROFILE,
brief: { ...BRIEF, targetLanguage: '普通话' },
config: CONFIG,
imageCount: 1,
});
expect(mandarinPlan.shots.every(shot => shot.voiceoverZh === '')).toBe(true);
expect('translationSegments' in mandarinPlan).toBe(false);
});
it.each([
['套头穿着', '正确使用方式:套头穿着。'],
['套头穿着。', '正确使用方式:套头穿着。'],
['套头穿着。。', '正确使用方式:套头穿着。'],
['套头穿着.', '正确使用方式:套头穿着。'],
['套头穿着;', '正确使用方式:套头穿着。'],
['是否套头穿着?', '正确使用方式:是否套头穿着?'],
['请套头穿着!', '正确使用方式:请套头穿着!'],
])('动态一致性字段统一句末标点:%s', (usage, expectedLine) => {
const block = buildPromptWizardConstraintBlock({
...PROFILE,
materials: '哑光塑料。。',
structure: '可折叠头梁;',
logo: '右侧耳罩有 Logo.',
usage,
consistencyConstraints: '',
userConfirmedClaims: '',
}, 1);
expect(block).toContain('材质:哑光塑料;结构:可折叠头梁;Logo:右侧耳罩有 Logo。');
expect(block).toContain(expectedLine);
expect(block).not.toMatch(/(?:。。|\.。|;。)/u);
});
it('外语原文存在时必须提供简体中文译文,译文也参与商业事实安全校验', () => {
const strategy = allocatePromptWizardStrategies({
runId: 'translation-validation',
ordinal: 0,
previousSignatures: [],
})[0]!;
const missingTranslation = makeDraft(4);
missingTranslation.segments[0] = {
...missingTranslation.segments[0]!,
voiceoverZh: '',
};
const baseInput = {
id: '00000000-0000-4000-8000-000000000094',
strategy,
profile: PROFILE,
brief: BRIEF,
config: CONFIG,
imageCount: 1,
};
expect(() => renderPromptWizardPlan({ ...baseInput, draft: missingTranslation }))
.toThrow(expect.objectContaining({
code: 'PROMPT_WIZARD_TRANSLATION_INVALID',
fieldPaths: ['segments[0].voiceoverZh'],
}));
const unsafeTranslation = makeDraft(4);
unsafeTranslation.segments[0] = {
...unsafeTranslation.segments[0]!,
voiceover: 'A quieter commute.',
voiceoverZh: '支持 40dB 主动降噪。',
};
expect(() => renderPromptWizardPlan({ ...baseInput, draft: unsafeTranslation }))
.toThrow(expect.objectContaining({ code: 'PROMPT_WIZARD_UNCONFIRMED_CLAIM' }));
});
it('拒绝英文、西班牙语及少量中文包裹的大段外语方案字段', () => {
const strategy = allocatePromptWizardStrategies({
runId: 'content-language-invalid',
ordinal: 0,
previousSignatures: [],
})[0]!;
const draft = makeDraft(4);
draft.name = 'Express Product Challenge';
draft.hook = 'Reto de tiempo limitado enfocado en detalles';
draft.narrative = '中文 This narrative remains overwhelmingly in English';
draft.differenceTags[0] = 'Minimalist studio';
draft.segments[0] = {
...draft.segments[0]!,
action: 'Vista cenital de la playera sobre un fondo neutro',
};
expect(() => renderPromptWizardPlan({
id: '00000000-0000-4000-8000-000000000090',
draft,
strategy,
profile: PROFILE,
brief: { ...BRIEF, targetLanguage: '西班牙语' },
config: CONFIG,
imageCount: 1,
})).toThrow(expect.objectContaining({
code: 'PROMPT_WIZARD_CONTENT_LANGUAGE_INVALID',
fieldPaths: expect.arrayContaining([
'name',
'hook',
'narrative',
'differenceTags[0]',
'segments[0].action',
]),
}));
});
it('允许 NFKC 归一化缩写、图片引用和产品档案中的品牌型号混排', () => {
const strategy = allocatePromptWizardStrategies({
runId: 'content-language-allowed-tokens',
ordinal: 0,
previousSignatures: [],
})[0]!;
const profile = {
...PROFILE,
productName: 'iPhone 16 Pro 手机',
logo: '机身印有 Apple Logo 与 MEXICO 字样',
};
const draft = makeDraft(4);
draft.name = 'iPhone 16 Pro 开箱';
draft.hook = '真实 UGC 展示 MEXICO 印花';
draft.narrative = '通过 ASMR 细节推进产品开箱';
draft.primaryScene = 'TikTok 风格桌面';
draft.cameraAndPacing = 'POV 近景配合 Seedance 运镜';
draft.differenceTags = ['UGC实拍', 'CTA收束'];
draft.segments[0] = {
...draft.segments[0]!,
action: '近景展示 @图片1 的 iPhone 16 Pro 与 MEXICO 印花',
voiceover: '¿Listo para verlo?',
voiceoverZh: '准备好看看它了吗?',
};
const plan = renderPromptWizardPlan({
id: '00000000-0000-4000-8000-000000000089',
draft,
strategy,
profile,
brief: { ...BRIEF, targetLanguage: '西班牙语' },
config: CONFIG,
imageCount: 1,
});
expect(plan.name).toBe('iPhone 16 Pro 开箱');
expect(plan.prompt).toContain('动作:近景展示 @图片1 的 iPhone 16 Pro 与 MEXICO 印花');
expect(plan.prompt).toContain('旁白(西班牙语):¿Listo para verlo?');
expect(plan.prompt).not.toContain('准备好看看它了吗?');
});
it('读取旧方案时只精确删除固定禁止事项尾段', () => {
expect(normalizePromptWizardProductionPrompt(
`生产正文\n\n${LEGACY_PROMPT_WIZARD_PROHIBITION}`,
)).toBe('生产正文');
expect(normalizePromptWizardProductionPrompt(
`正文中提到${LEGACY_PROMPT_WIZARD_PROHIBITION}\n但后面还有用户内容`,
)).toContain(LEGACY_PROMPT_WIZARD_PROHIBITION);
expect(normalizePromptWizardProductionPrompt('用户自己写的【禁止事项】不得改变外观。'))
.toBe('用户自己写的【禁止事项】不得改变外观。');
});
it('拒绝引用当前全能参考图片集合以外的图片', () => {
const strategy = allocatePromptWizardStrategies({
runId: 'invalid-image-reference',
ordinal: 0,
previousSignatures: [],
})[0]!;
const draft = makeDraft(4);
draft.segments[0] = {
...draft.segments[0]!,
action: '近景展示 @图片2 的外观',
};
expect(() => renderPromptWizardPlan({
id: '00000000-0000-4000-8000-000000000098',
draft,
strategy,
profile: PROFILE,
brief: BRIEF,
config: CONFIG,
imageCount: 1,
})).toThrow(expect.objectContaining({
code: 'PROMPT_WIZARD_REFERENCE_INVALID',
fieldPaths: ['segments[0].action'],
}));
});
it.each(['使用 @图片x 展示外观', '加入 @视频1 的镜头', '跟随 @音频1 的节拍'])(
'拒绝非当前图片集合的引用:%s',
visual => {
const strategy = allocatePromptWizardStrategies({
runId: `invalid-reference-${visual}`,
ordinal: 0,
previousSignatures: [],
})[0]!;
const draft = makeDraft(4);
draft.segments[0] = { ...draft.segments[0]!, action: visual };
expect(() => renderPromptWizardPlan({
id: '00000000-0000-4000-8000-000000000096',
draft,
strategy,
profile: PROFILE,
brief: BRIEF,
config: CONFIG,
imageCount: 1,
})).toThrow(expect.objectContaining({
code: 'PROMPT_WIZARD_REFERENCE_INVALID',
fieldPaths: ['segments[0].action'],
}));
},
);
it.each([
'首帧固定产品正面',
'尾 帧展示购买按钮',
'将第一_帧作为参考',
'Use the first frame as reference',
'Use first_frame as reference',
'Use firstFrame as reference',
'Use last-frame as reference',
])(
'拒绝全能参考提示词中的首尾帧语义:%s',
visual => {
const strategy = allocatePromptWizardStrategies({
runId: `first-last-${visual}`,
ordinal: 0,
previousSignatures: [],
})[0]!;
const draft = makeDraft(4);
draft.segments[0] = { ...draft.segments[0]!, action: visual };
expect(() => renderPromptWizardPlan({
id: '00000000-0000-4000-8000-000000000097',
draft,
strategy,
profile: PROFILE,
brief: BRIEF,
config: CONFIG,
imageCount: 1,
})).toThrow(expect.objectContaining({
code: 'PROMPT_WIZARD_REFERENCE_MODE_INVALID',
fieldPaths: ['segments[0].action'],
}));
},
);
it('允许不涉及参考帧的首尾呼应叙事表达', () => {
const strategy = allocatePromptWizardStrategies({
runId: 'first-last-narrative-safe',
ordinal: 0,
previousSignatures: [],
})[0]!;
const draft = makeDraft(4);
draft.segments[0] = { ...draft.segments[0]!, editingCue: '首尾呼应并自然收束' };
expect(() => renderPromptWizardPlan({
id: '00000000-0000-4000-8000-000000000098',
draft,
strategy,
profile: PROFILE,
brief: BRIEF,
config: CONFIG,
imageCount: 1,
})).not.toThrow();
});
it('拒绝未经用户确认的数字卖点,并允许已确认事实', () => {
const strategy = allocatePromptWizardStrategies({
runId: 'commercial-claim-check',
ordinal: 0,
previousSignatures: [],
})[0]!;
const draft = makeDraft(4);
draft.segments[0] = {
...draft.segments[0]!,
voiceover: '40dB active noise cancellation',
};
const input = {
id: '00000000-0000-4000-8000-000000000095',
draft,
strategy,
profile: PROFILE,
brief: BRIEF,
config: CONFIG,
imageCount: 1,
};
let rejected: unknown;
try {
renderPromptWizardPlan(input);
} catch (error) {
rejected = error;
}
expect(rejected).toEqual(expect.objectContaining({
code: 'PROMPT_WIZARD_UNCONFIRMED_CLAIM',
fieldPaths: expect.arrayContaining(['segments[0].voiceover']),
}));
expect((rejected as Error).message).not.toContain('40dB active noise cancellation');
expect(() => renderPromptWizardPlan({
...input,
profile: { ...PROFILE, userConfirmedClaims: '40dB 主动降噪' },
})).not.toThrow();
});
it.each([
['防水材质', '不防水'],
['防水材质', '未实现防水'],
['防水材质', '无防水能力'],
['防水材质', '不能防水'],
['防水材质', '不支持防水'],
['防水材质', '尚未实现防水'],
['支持 40dB 主动降噪', 'not support 40dB 主动降噪'],
['支持 40dB 主动降噪', 'no 40dB 主动降噪 capability'],
['支持 40dB 主动降噪', 'without 40dB 主动降噪'],
['支持 40dB 主动降噪', 'cannot support 40dB 主动降噪'],
['支持 40dB 主动降噪', "can't support 40dB 主动降噪"],
['支持 40dB 主动降噪', "doesn't support 40dB 主动降噪"],
['支持 40dB 主动降噪', '40dB 主动降噪 is unsupported'],
])('否定确认不能授权正向 Draft 主张:“%s” ← “%s”', (hook, confirmedClaims) => {
const strategy = allocatePromptWizardStrategies({
runId: `commercial-polarity-${confirmedClaims}`,
ordinal: 0,
previousSignatures: [],
})[0]!;
const draft = makeDraft(4);
draft.hook = hook;
expect(() => renderPromptWizardPlan({
id: '00000000-0000-4000-8000-000000000081',
draft,
strategy,
profile: { ...PROFILE, userConfirmedClaims: confirmedClaims },
brief: BRIEF,
config: CONFIG,
imageCount: 1,
})).toThrow(expect.objectContaining({
code: 'PROMPT_WIZARD_UNCONFIRMED_CLAIM',
fieldPaths: expect.arrayContaining(['hook']),
}));
});
it.each([
['防水材质', '可能防水'],
['防水材质', '防水能力待确认'],
['防水材质', '尚未确认防水'],
['支持 40dB 主动降噪', 'possibly 40dB 主动降噪'],
['支持 40dB 主动降噪', '40dB 主动降噪 unconfirmed'],
['支持 40dB 主动降噪', 'may be 40dB 主动降噪'],
])('不确定确认不能授权正向 Draft 主张:“%s” ← “%s”', (hook, confirmedClaims) => {
const strategy = allocatePromptWizardStrategies({
runId: `commercial-uncertain-${confirmedClaims}`,
ordinal: 0,
previousSignatures: [],
})[0]!;
const draft = makeDraft(4);
draft.hook = hook;
expect(() => renderPromptWizardPlan({
id: '00000000-0000-4000-8000-000000000080',
draft,
strategy,
profile: { ...PROFILE, userConfirmedClaims: confirmedClaims },
brief: BRIEF,
config: CONFIG,
imageCount: 1,
})).toThrow(expect.objectContaining({
code: 'PROMPT_WIZARD_UNCONFIRMED_CLAIM',
fieldPaths: expect.arrayContaining(['hook']),
}));
});
it.each([
['防水材质', '确认防水材质'],
['防水材质', '确认材质非常防水'],
['防水材质', '确认采用无缝防水设计'],
['防水材质', '确认具有非凡防水表现'],
['防水材质', '确认不锈钢机身采用防水设计'],
['这款材质不防水', '确认这款材质不防水'],
])('极性一致且语义相同的事实继续放行:“%s” ← “%s”', (hook, confirmedClaims) => {
const strategy = allocatePromptWizardStrategies({
runId: `commercial-same-polarity-${confirmedClaims}`,
ordinal: 0,
previousSignatures: [],
})[0]!;
const draft = makeDraft(4);
draft.hook = hook;
expect(() => renderPromptWizardPlan({
id: '00000000-0000-4000-8000-000000000079',
draft,
strategy,
profile: { ...PROFILE, userConfirmedClaims: confirmedClaims },
brief: BRIEF,
config: CONFIG,
imageCount: 1,
})).not.toThrow();
});
it('否定确认不能授权正向数字主张,相同负向数字事实仍可放行', () => {
const strategy = allocatePromptWizardStrategies({
runId: 'commercial-numeric-polarity',
ordinal: 0,
previousSignatures: [],
})[0]!;
const positiveDraft = makeDraft(4);
positiveDraft.hook = '支持 40dB 主动降噪';
const input = {
id: '00000000-0000-4000-8000-000000000078',
strategy,
profile: { ...PROFILE, userConfirmedClaims: '不支持 40dB 主动降噪' },
brief: BRIEF,
config: CONFIG,
imageCount: 1,
};
expect(() => renderPromptWizardPlan({ ...input, draft: positiveDraft }))
.toThrow(expect.objectContaining({
code: 'PROMPT_WIZARD_UNCONFIRMED_CLAIM',
fieldPaths: expect.arrayContaining(['hook']),
}));
const negativeDraft = makeDraft(4);
negativeDraft.hook = '不支持 40dB 主动降噪';
expect(() => renderPromptWizardPlan({ ...input, draft: negativeDraft })).not.toThrow();
});
it('商业事实门禁遍历 Draft 字符串叶子并返回全部安全字段路径', () => {
const strategy = allocatePromptWizardStrategies({
runId: 'commercial-claim-field-paths',
ordinal: 0,
previousSignatures: [],
})[0]!;
const draft = makeDraft(4);
draft.hook = '支持 40dB 主动降噪';
draft.segments[0] = {
...draft.segments[0]!,
voiceover: '30 hours battery life',
voiceoverZh: '支持 30 小时续航',
};
let rejected: unknown;
try {
renderPromptWizardPlan({
id: '00000000-0000-4000-8000-000000000085',
draft,
strategy,
profile: PROFILE,
brief: BRIEF,
config: CONFIG,
imageCount: 1,
});
} catch (error) {
rejected = error;
}
expect(rejected).toEqual(expect.objectContaining({
code: 'PROMPT_WIZARD_UNCONFIRMED_CLAIM',
fieldPaths: expect.arrayContaining([
'hook',
'segments[0].voiceover',
'segments[0].voiceoverZh',
]),
}));
expect((rejected as Error).message).not.toContain('40dB');
expect((rejected as Error).message).not.toContain('30 hours');
expect((rejected as Error).message).not.toContain('30 小时');
});
it.each([
['100% cotton', '100% satisfaction guaranteed'],
['100%棉', '100%防水'],
['30小时续航', '30小时防褪色'],
])('确认“%s”不会放行语义不同但数值相同的“%s”', (confirmedClaims, voiceover) => {
const strategy = allocatePromptWizardStrategies({
runId: `commercial-context-${confirmedClaims}`,
ordinal: 0,
previousSignatures: [],
})[0]!;
const draft = makeDraft(4);
draft.segments[0] = { ...draft.segments[0]!, voiceover };
expect(() => renderPromptWizardPlan({
id: '00000000-0000-4000-8000-000000000084',
draft,
strategy,
profile: { ...PROFILE, userConfirmedClaims: confirmedClaims },
brief: BRIEF,
config: CONFIG,
imageCount: 1,
})).toThrow(expect.objectContaining({
code: 'PROMPT_WIZARD_UNCONFIRMED_CLAIM',
fieldPaths: expect.arrayContaining(['segments[0].voiceover']),
}));
});
it.each([
['100% cotton', '100% cotton'],
['100%棉', '100% cotton'],
['30小时续航', '30 hours battery life'],
])('相同商业语义仍可跨中英文白名单放行:“%s” → “%s”', (confirmedClaims, voiceover) => {
const strategy = allocatePromptWizardStrategies({
runId: `commercial-context-allowed-${confirmedClaims}`,
ordinal: 0,
previousSignatures: [],
})[0]!;
const draft = makeDraft(4);
draft.segments[0] = { ...draft.segments[0]!, voiceover };
expect(() => renderPromptWizardPlan({
id: '00000000-0000-4000-8000-000000000083',
draft,
strategy,
profile: { ...PROFILE, userConfirmedClaims: confirmedClaims },
brief: BRIEF,
config: CONFIG,
imageCount: 1,
})).not.toThrow();
});
it('渲染一致性块后再次执行商业事实门禁,同时不误拒用户确认字段本身', () => {
const strategy = allocatePromptWizardStrategies({
runId: 'commercial-final-prompt-check',
ordinal: 0,
previousSignatures: [],
})[0]!;
const input = {
id: '00000000-0000-4000-8000-000000000082',
draft: makeDraft(4),
strategy,
brief: BRIEF,
config: CONFIG,
imageCount: 1,
};
expect(() => renderPromptWizardPlan({
...input,
profile: { ...PROFILE, consistencyConstraints: '产品支持 30 小时防褪色' },
})).toThrow(expect.objectContaining({
code: 'PROMPT_WIZARD_UNCONFIRMED_CLAIM',
fieldPaths: ['prompt'],
}));
expect(() => renderPromptWizardPlan({
...input,
profile: { ...PROFILE, userConfirmedClaims: '100%棉' },
})).not.toThrow();
});
it('拒绝错误镜头职责和无法追溯或待确认的产品自证', () => {
const strategy = allocatePromptWizardStrategies({
runId: 'beat-and-evidence-check',
ordinal: 0,
previousSignatures: [],
})[0]!;
const input = {
id: '00000000-0000-4000-8000-000000000087',
strategy,
profile: PROFILE,
brief: BRIEF,
config: CONFIG,
imageCount: 1,
};
const wrongBeat = makeDraft(4);
wrongBeat.segments[1] = { ...wrongBeat.segments[1]!, role: 'resolution' };
expect(() => renderPromptWizardPlan({ ...input, draft: wrongBeat })).toThrow(
expect.objectContaining({
code: 'PROMPT_WIZARD_BEAT_INVALID',
fieldPaths: ['segments[1].role'],
}),
);
const unknownEvidence = makeDraft(4);
unknownEvidence.segments[1] = {
...unknownEvidence.segments[1]!,
evidenceRefs: ['needsConfirmation:1'],
};
expect(() => renderPromptWizardPlan({ ...input, draft: unknownEvidence })).toThrow(
expect.objectContaining({
code: 'PROMPT_WIZARD_EVIDENCE_INVALID',
fieldPaths: expect.arrayContaining(['segments[1].evidenceRefs[0]']),
}),
);
const pendingProof = makeDraft(4);
pendingProof.planningReview.proofPoints = ['续航时长需由用户确认'];
expect(() => renderPromptWizardPlan({ ...input, draft: pendingProof })).toThrow(
expect.objectContaining({
code: 'PROMPT_WIZARD_EVIDENCE_INVALID',
fieldPaths: expect.arrayContaining(['planningReview.proofPoints[0]']),
}),
);
const uncertainMaterial = makeDraft(4);
uncertainMaterial.planningReview.proofPoints = ['无法仅凭图片确定,可能为聚酯纤维'];
expect(() => renderPromptWizardPlan({
...input,
draft: uncertainMaterial,
profile: { ...PROFILE, materials: '无法仅凭图片确定,可能为聚酯纤维' },
})).toThrow(expect.objectContaining({ code: 'PROMPT_WIZARD_EVIDENCE_INVALID' }));
});
it('传入证据图谱后以图谱为 V4 权威事实源,不再被 profile 目录放宽', () => {
const strategy = allocatePromptWizardStrategies({
runId: 'authoritative-evidence-graph',
ordinal: 0,
previousSignatures: [],
})[0]!;
const imageAssetId = '00000000-0000-4000-8000-000000000088';
const evidenceGraph = buildPromptWizardEvidenceGraph({
profile: PROFILE,
imageAssetIds: [imageAssetId],
detectedEvidenceSources: [{
fieldPath: 'visibleFacts[0]',
imageAssetIds: [imageAssetId],
visibleRegion: '产品主体',
confidence: 'high',
proofActions: ['稳定近景展示黑色外观'],
}],
});
const input = {
id: '00000000-0000-4000-8000-000000000089',
strategy,
profile: PROFILE,
evidenceGraph,
brief: BRIEF,
config: CONFIG,
imageCount: 1,
};
const validDraft = makeDraft(4);
validDraft.planningReview.proofPoints = ['黑色外观'];
expect(() => renderPromptWizardPlan({ ...input, draft: validDraft })).not.toThrow();
const profileOnlyEvidence = makeDraft(4);
profileOnlyEvidence.planningReview.proofPoints = ['黑色外观'];
profileOnlyEvidence.segments[1] = {
...profileOnlyEvidence.segments[1]!,
evidenceRefs: ['profile:structure'],
};
expect(() => renderPromptWizardPlan({ ...input, draft: profileOnlyEvidence })).toThrow(
expect.objectContaining({
code: 'PROMPT_WIZARD_EVIDENCE_INVALID',
fieldPaths: expect.arrayContaining(['segments[1].evidenceRefs[0]']),
}),
);
});
it.each([
'全网都在买这款产品',
'只剩 3 件,马上售罄',
'把产品放到明火燃烧中测试',
'产品会凭空变出并瞬间自我修复',
'效果碾压竞品',
])('拒绝高风险脚本指令:%s', unsafeAction => {
const strategy = allocatePromptWizardStrategies({
runId: `unsafe-content-${unsafeAction}`,
ordinal: 0,
previousSignatures: [],
})[0]!;
const draft = makeDraft(4);
draft.segments[0] = { ...draft.segments[0]!, action: unsafeAction };
const expectedError = unsafeAction.includes('凭空变出')
? {
code: 'PROMPT_WIZARD_EXECUTION_BUDGET_EXCEEDED',
fieldPaths: ['segments'],
}
: {
code: 'PROMPT_WIZARD_UNSAFE_CONTENT',
fieldPaths: ['segments[0].action'],
};
expect(() => renderPromptWizardPlan({
id: '00000000-0000-4000-8000-000000000086',
draft,
strategy,
profile: PROFILE,
brief: BRIEF,
config: CONFIG,
imageCount: 1,
})).toThrow(expect.objectContaining(expectedError));
});
it.each([
['图片引用', '产品外观需继续参考 @图片2', 'PROMPT_WIZARD_REFERENCE_INVALID'],
['首尾帧语义', '尾帧固定展示产品正面', 'PROMPT_WIZARD_REFERENCE_MODE_INVALID'],
['高风险内容', '只剩 3 件,马上售罄', 'PROMPT_WIZARD_UNSAFE_CONTENT'],
])('最终产品约束中的%s标记为 prompt,不能伪装修复 Draft', (_, constraint, code) => {
const strategy = allocatePromptWizardStrategies({
runId: `final-prompt-${code}`,
ordinal: 0,
previousSignatures: [],
})[0]!;
expect(() => renderPromptWizardPlan({
id: '00000000-0000-4000-8000-000000000085',
draft: makeDraft(4),
strategy,
profile: { ...PROFILE, consistencyConstraints: constraint },
brief: BRIEF,
config: CONFIG,
imageCount: 1,
})).toThrow(expect.objectContaining({
code,
fieldPaths: ['prompt'],
}));
});
it.each([
['UL 认证', ''],
['支持快充,续航表现出色', ''],
['40dB active noise cancellation', '140dB 主动降噪'],
])('拒绝未确认或仅被更长数字片段覆盖的商业事实:%s', (voiceover, confirmedClaims) => {
const strategy = allocatePromptWizardStrategies({
runId: `commercial-claim-${voiceover}`,
ordinal: 0,
previousSignatures: [],
})[0]!;
const draft = makeDraft(4);
draft.segments[0] = { ...draft.segments[0]!, voiceover };
expect(() => renderPromptWizardPlan({
id: '00000000-0000-4000-8000-000000000094',
draft,
strategy,
profile: { ...PROFILE, userConfirmedClaims: confirmedClaims },
brief: BRIEF,
config: CONFIG,
imageCount: 1,
})).toThrow(expect.objectContaining({
code: 'PROMPT_WIZARD_UNCONFIRMED_CLAIM',
}));
});
});
describe('提示词向导创意策略与历史隔离', () => {
it('同批两个方案任意两者至少有四个创意轴不同', () => {
for (let ordinal = 0; ordinal < 20; ordinal += 1) {
const strategies = allocatePromptWizardStrategies({
runId: 'strategy-axis-run',
ordinal,
previousSignatures: [],
});
expect(new Set(strategies.map(strategySignature)).size).toBe(PROMPT_WIZARD_PLAN_COUNT);
forEachPair(strategies, (left, right) => {
expect(countDifferentStrategyAxes(left, right)).toBeGreaterThanOrEqual(4);
});
}
});
it.each([
'ugc',
'problem-solution',
'unboxing-review',
'feature-test',
'pain-comparison',
'lifestyle',
'cinematic-brand',
'asmr-detail',
] as const)('%s 在方向内部仍生成两套至少四轴不同的策略', direction => {
for (let ordinal = 0; ordinal < 8; ordinal += 1) {
const strategies = allocatePromptWizardStrategies({
runId: `direction-strategy-${direction}`,
ordinal,
previousSignatures: [],
direction,
});
forEachPair(strategies, (left, right) => {
expect(countDifferentStrategyAxes(left, right)).toBeGreaterThanOrEqual(4);
});
}
});
it('同一 run 避开已使用签名,新 run 由调用方传入空历史独立开始', () => {
const firstBatch = allocatePromptWizardStrategies({
runId: 'same-run',
ordinal: 0,
previousSignatures: [],
});
const usedSignatures = new Set(firstBatch.map(strategySignature));
const refreshBatch = allocatePromptWizardStrategies({
runId: 'same-run',
ordinal: 1,
previousSignatures: usedSignatures,
});
expect(refreshBatch.every(strategy => !usedSignatures.has(strategySignature(strategy)))).toBe(true);
const historicalPlans = firstBatch.map((strategy, index) => makePlan(
strategy,
`当前任务历史正文 ${index}`,
index,
));
expect(validatePromptWizardPlanHistory(historicalPlans, historicalPlans))
.toEqual(expect.arrayContaining([
expect.objectContaining({ reason: '策略签名与当前任务历史批次重复' }),
]));
const newRunBatch = allocatePromptWizardStrategies({
runId: 'new-run',
ordinal: 0,
previousSignatures: [],
}).map((strategy, index) => makePlan(strategy, `新任务正文 ${index}`, index));
expect(validatePromptWizardPlanHistory(newRunBatch, [])).toEqual([]);
});
});
describe('提示词向导文本相似度阈值', () => {
it('固定使用 Jaccard 0.45 与 SimHash 汉明距离 10 的判定阈值', () => {
expect(PROMPT_WIZARD_MAX_JACCARD).toBe(0.45);
expect(PROMPT_WIZARD_MIN_SIMHASH_DISTANCE).toBe(10);
const left = '地铁通勤中先呈现噪声困扰,再近景展示佩戴耳机的操作。\n【产品一致性硬约束】黑色耳机';
const right = '地铁通勤中先呈现噪声困扰,再近景展示佩戴耳机的操作。\n【产品一致性硬约束】白色耳机';
expect(creativeBody(left)).toBe(creativeBody(right));
expect(trigramJaccard(creativeBody(left), creativeBody(right))).toBe(1);
expect(simHashHammingDistance(creativeBody(left), creativeBody(right))).toBe(0);
});
it('相同创意正文即使产品约束不同,仍同时触发两种相似度拒绝', () => {
const strategies = allocatePromptWizardStrategies({
runId: 'similar-copy',
ordinal: 0,
previousSignatures: [],
});
const plans = strategies.map((strategy, index) => makePlan(
strategy,
`完全相同的创意正文。\n【产品一致性硬约束】约束版本 ${index}`,
index,
));
const issues = validatePromptWizardPlanDiversity(plans);
expect(issues.some(issue => issue.reason.includes('Jaccard 相似度'))).toBe(true);
expect(issues.some(issue => issue.reason.includes('SimHash 汉明距离'))).toBe(true);
});
it('V4 不依赖备选 Hook,方案摘要与分镜近似时仍被多样性门禁拒绝', () => {
const strategies = allocatePromptWizardStrategies({
runId: 'v3-similar-without-hooks',
ordinal: 0,
previousSignatures: [],
});
const plans = strategies.map((strategy, index) => renderPromptWizardPlan({
id: `00000000-0000-4000-8000-${String(200 + index).padStart(12, '0')}`,
draft: makeDraft(4),
strategy,
profile: PROFILE,
brief: BRIEF,
config: CONFIG,
imageCount: 1,
}));
expect(plans.every(plan => plan.deliveryVersion === 4 && !('alternativeHooks' in plan))).toBe(true);
const issues = validatePromptWizardPlanDiversity(plans);
expect(issues.some(issue => issue.reason.includes('Jaccard 相似度'))).toBe(true);
expect(issues.some(issue => issue.reason.includes('SimHash 汉明距离'))).toBe(true);
});
});
function makeDraft(segmentCount: number): PromptWizardPlanDraft {
const roles = segmentCount === 3
? ['hook', 'proof-resolution', 'cta'] as const
: ['hook', 'proof', 'resolution', 'cta'] as const;
return {
name: '通勤反差实测',
hook: '嘈杂环境突然安静',
narrative: '从通勤困扰进入真实佩戴和操作展示',
primaryScene: '早高峰地铁车厢',
cameraAndPacing: '手持近景配合节拍点切换',
differenceTags: ['真实通勤', '操作闭环'],
planningReview: {
targetAudience: '需要在通勤途中使用耳机的人群',
surfaceNeed: '希望随身设备容易佩戴和收纳',
realPainPoint: '拥挤通勤中操作步骤复杂会打断行动节奏',
nonAdEntry: '从早高峰拿取和佩戴动作自然切入,不先讲卖点',
proofPoints: ['黑色外观', '头梁可以折叠'],
directionFit: '以真实人物和连续操作体现真实 UGC 的自然感',
},
segments: Array.from({ length: segmentCount }, (_, index) => ({
role: roles[index]!,
location: index === 0 ? '早高峰地铁站台' : '连续的地铁通勤空间',
subject: index === 0 ? '人物手持 @图片1 对应耳机' : '人物与同一耳机',
shotSize: index === 0 ? '手部近景' : '中近景',
action: index === 0
? '人物从包中拿出耳机并准备佩戴'
: `第 ${index + 1} 段展示产品真实操作与场景反馈`,
cameraMovement: '手持贴身跟拍并保持产品结构清楚',
lightingAndRealism: '使用站台自然环境光,保留轻微手机实拍呼吸感',
voiceover: `Voiceover ${index + 1}`,
voiceoverZh: `口播翻译 ${index + 1}`,
foley: '环境脚步声、布料摩擦声与真实按键声',
editingCue: '跟随动作节点切换,不跳过关键使用步骤',
evidenceRefs: roles[index] === 'cta' ? [] : ['visible:1'],
productionNote: '确保耳机外观与参考图片一致',
})),
productionWarnings: ['复杂 Logo 和可读印花必须严格参考原图,不新增文字'],
};
}
function makePlan(
strategy: PromptWizardStrategy,
prompt: string,
index: number,
): PromptWizardPlan {
const plan = renderPromptWizardPlan({
id: `00000000-0000-4000-8000-${String(100 + index).padStart(12, '0')}`,
draft: makeDraft(4),
strategy,
profile: PROFILE,
brief: BRIEF,
config: CONFIG,
imageCount: 1,
});
return {
...plan,
prompt,
};
}
function forEachPair<T>(items: T[], assertion: (left: T, right: T) => void) {
for (let leftIndex = 0; leftIndex < items.length; leftIndex += 1) {
for (let rightIndex = leftIndex + 1; rightIndex < items.length; rightIndex += 1) {
assertion(items[leftIndex]!, items[rightIndex]!);
}
}
}
function formatSecond(value: number) {
return Number.isInteger(value) ? String(value) : value.toFixed(1);
}