Files
infiplot-web/scripts/test-phase5.mjs
T
Zonghao Yuan 0e4c2ebef4 feat(engine): merge cloudflare-migration — paradigm D engine, BYOK proxy, story persistence (#95)
Squash-merge the cloudflare-migration branch (7 commits by Kai ki) into
staging with conflict resolution, feature integration, and bug fixes.

Engine:
- Paradigm D: single-stream Writer replacing dual-phase Plan/Beats
- Delete Architect agent; story bible generated via Writer <plan> tag
- Modular prompt architecture (segments/registry/builder)
- StreamRouter for tagged stream splitting (<plan>/<story>/<choices>)

Infrastructure:
- Cloudflare Workers deployment (wrangler.jsonc, OpenNext adapter)
- D1 database schema + Drizzle ORM (scaffolded, not yet active)
- R2 storage helpers (scaffolded, not yet active)
- Story persistence API routes + client-side persistence

BYOK (Bring Your Own Key):
- /api/llm/user-proxy with SSRF-protected LLM proxy (+ requireUser auth)
- CORS-aware fetch in ai-client: auto-detect CORS failure, fallback to
  server proxy transparently via OpenAI SDK custom fetch
- BYO config support added to classify-freeform and vision routes
- SettingsModal CORS privacy notice (keys never logged/stored)

SSE streaming:
- engineClient.ts: fetchSSE helper for progressive scene events
- startSession/requestScene accept optional emit callback
- Fix SSE error event field name (error → message) in scene/start routes

i18n integration:
- Wire buildLanguageDirective into paradigm D's prompt builder
- Update corsNotice i18n keys (zh-CN/en/ja) with CORS proxy privacy text
- Preserve Session.language + LanguageSwitcher from i18n commit

Co-authored-by: Kai ki <155355644+zbf1009@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-18 18:05:38 +08:00

509 lines
19 KiB
JavaScript
Raw 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.
#!/usr/bin/env node
/**
* Phase 5 验证测试脚本
*
* 用途:
* - Task 18: 禁词表验证(生成10场景,统计禁词)
* - Task 20: CharacterPersona 注入验证
* - Task 21: 世界书触发验证
* - Task 22: Prompt Cache 命中率监控
* - Task 23: Token 预算验证
*
* 使用方法:
* node scripts/test-phase5.mjs --task=18 --url=https://infiplot.y-9e6.workers.dev
*/
import { promises as fs } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// 禁词表(来自 lib/engine/prompts/segments/writer/style-base.ts
const FORBIDDEN_WORDS = [
"一丝", "不易察觉", "鲜明对比", "喉结", "纽扣", "弧度",
"不禁", "悄然", "涟漪", "交织"
];
// 命令行参数解析
const args = process.argv.slice(2).reduce((acc, arg) => {
const [key, value] = arg.split("=");
acc[key.replace("--", "")] = value || true;
return acc;
}, {});
const BASE_URL = args.url || "https://infiplot.y-9e6.workers.dev";
const TASK = args.task || "18";
console.log(`🔍 Phase 5 验证测试 - Task ${TASK}`);
console.log(`📍 目标环境: ${BASE_URL}\n`);
// ──────────────────────────────────────────────────────────────────────
// Task 18: 禁词表验证
// ──────────────────────────────────────────────────────────────────────
async function task18_forbiddenWords() {
console.log("📋 Task 18: 禁词表验证(生成10场景统计禁词)\n");
const scenarios = [
{ type: "开局", seed: "一个平凡的清晨,主角醒来发现窗外有奇怪的光" },
{ type: "对话", seed: "两个角色在咖啡厅里讨论一个秘密" },
{ type: "动作", seed: "主角在图书馆里发现了一本禁书" },
{ type: "情感", seed: "两个朋友因为误会产生了隔阂" },
{ type: "悬疑", seed: "主角收到了一封没有署名的信" },
{ type: "冲突", seed: "主角和反派在天台对峙" },
{ type: "浪漫", seed: "两个人在雨中相遇" },
{ type: "惊悚", seed: "主角发现镜子里的倒影不是自己" },
{ type: "日常", seed: "主角在学校食堂排队买午饭" },
{ type: "转折", seed: "主角发现自己信任的人背叛了自己" }
];
const results = [];
let totalForbiddenCount = 0;
let totalCharCount = 0;
for (let i = 0; i < scenarios.length; i++) {
const scenario = scenarios[i];
console.log(`\n🎬 [${i + 1}/10] 场景类型: ${scenario.type}`);
console.log(` 开场种子: ${scenario.seed}`);
try {
// 调用 /api/start
const startRes = await fetch(`${BASE_URL}/api/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
worldSetting: "现代都市,有超自然元素",
styleGuide: "写实风格,带一点魔幻色彩",
openingPrompt: scenario.seed,
orientation: "landscape"
})
});
if (!startRes.ok) {
console.error(` ❌ API 错误: ${startRes.status}`);
continue;
}
const data = await startRes.json();
// StartResponse: { sessionId, scene, imageUrl, characters, storyState }
const scene = data.scene;
if (!scene || !scene.beats) {
console.error(` ❌ 场景数据缺失`, JSON.stringify(Object.keys(data)));
continue;
}
// 提取所有文本
const texts = scene.beats
.map(b => [b.narration, b.line].filter(Boolean).join(" "))
.join(" ");
totalCharCount += texts.length;
// 统计禁词
const forbiddenFound = {};
let sceneForbiddenCount = 0;
for (const word of FORBIDDEN_WORDS) {
const count = (texts.match(new RegExp(word, "g")) || []).length;
if (count > 0) {
forbiddenFound[word] = count;
sceneForbiddenCount += count;
}
}
totalForbiddenCount += sceneForbiddenCount;
console.log(` ✅ 生成成功 (${texts.length} 字)`);
if (sceneForbiddenCount > 0) {
console.log(` ⚠️ 禁词出现: ${sceneForbiddenCount} 次`);
for (const [word, count] of Object.entries(forbiddenFound)) {
console.log(` - "${word}": ${count} 次`);
}
} else {
console.log(` ✨ 无禁词`);
}
results.push({
type: scenario.type,
seed: scenario.seed,
textLength: texts.length,
forbiddenCount: sceneForbiddenCount,
forbiddenWords: forbiddenFound,
sceneKey: scene.sceneKey,
beatCount: scene.beats.length
});
} catch (err) {
console.error(` ❌ 请求失败: ${err.message}`);
}
// 避免 rate limit
await new Promise(resolve => setTimeout(resolve, 2000));
}
// 输出统计结果
console.log("\n\n" + "═".repeat(60));
console.log("📊 Task 18 统计结果");
console.log("═".repeat(60));
console.log(`生成场景: ${results.length} / 10`);
console.log(`总字数: ${totalCharCount.toLocaleString()} 字`);
console.log(`禁词总数: ${totalForbiddenCount} 次`);
console.log(`禁词密度: ${(totalForbiddenCount / totalCharCount * 10000).toFixed(2)} 次/万字`);
console.log(`\n期望目标: 禁词出现率下降 >80% (需要对比旧版本基线)`);
// 保存详细报告
const reportPath = join(__dirname, "../.spec-workflow/specs/prompt-architecture-redesign/task18-report.json");
await fs.writeFile(reportPath, JSON.stringify({
timestamp: new Date().toISOString(),
summary: {
scenesGenerated: results.length,
totalChars: totalCharCount,
totalForbiddenWords: totalForbiddenCount,
forbiddenDensity: totalForbiddenCount / totalCharCount * 10000
},
details: results
}, null, 2));
console.log(`\n📄 详细报告已保存: ${reportPath}`);
}
// ──────────────────────────────────────────────────────────────────────
// Task 20: CharacterPersona 注入验证
// ──────────────────────────────────────────────────────────────────────
async function task20_personaInjection() {
console.log("📋 Task 20: CharacterPersona 注入验证\n");
const testCases = [
{
name: "傲娇女生测试",
worldSetting: "现代校园",
styleGuide: "轻松日常风格",
openingPrompt: "主角在学校走廊遇到了同班的凛,她似乎有话要说",
expectedPersona: {
name: "凛",
persona: "傲娇女生,外冷内热,喜欢主角但嘴硬",
speakingStyle: "口头禅'哼',短句,语气强硬但偶尔露出温柔",
sampleDialogue: ["哼,才不是担心你呢!", "你…你别误会啊!"]
}
},
{
name: "沉默寡言少年测试",
worldSetting: "现代校园",
styleGuide: "安静温柔",
openingPrompt: "主角在图书馆遇到了总是独自看书的少年樱",
expectedPersona: {
name: "樱",
persona: "沉默寡言的少年,内心细腻,不善表达",
speakingStyle: "惜字如金,多用省略号和短句,语气平静",
sampleDialogue: ["嗯…", "……没什么。", "谢谢。"]
}
}
];
const results = [];
for (const testCase of testCases) {
console.log(`\n🎭 ${testCase.name}`);
console.log(` 角色: ${testCase.expectedPersona.name}`);
console.log(` Persona: ${testCase.expectedPersona.persona}`);
console.log(` 说话风格: ${testCase.expectedPersona.speakingStyle}`);
try {
// 第一次调用 /api/start,然后手动注入 persona(模拟后续场景)
const startRes = await fetch(`${BASE_URL}/api/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
worldSetting: testCase.worldSetting,
styleGuide: testCase.styleGuide,
openingPrompt: testCase.openingPrompt,
orientation: "landscape"
})
});
if (!startRes.ok) {
console.error(` ❌ API 错误: ${startRes.status}`);
continue;
}
const data = await startRes.json();
// Reconstruct a Session object from StartResponse
const session = {
id: data.sessionId,
createdAt: Date.now(),
worldSetting: testCase.worldSetting,
styleGuide: testCase.styleGuide,
history: [{
scene: data.scene,
visitedBeatIds: [data.scene.entryBeatId || data.scene.beats[0].id],
exit: null
}],
characters: data.characters,
storyState: data.storyState,
orientation: "landscape"
};
// 手动注入角色 persona(模拟已设计的角色)
const targetChar = session.characters.find(c => c.name === testCase.expectedPersona.name);
if (targetChar) {
Object.assign(targetChar, testCase.expectedPersona);
} else {
session.characters.push(testCase.expectedPersona);
}
// 调用 /api/scene 生成下一场景
const sceneRes = await fetch(`${BASE_URL}/api/scene`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session })
});
if (!sceneRes.ok) {
console.error(` ❌ Scene API 错误: ${sceneRes.status}`);
continue;
}
const sceneData = await sceneRes.json();
// SceneResponse: { scene, imageUrl, characters, storyState }
const scene = sceneData.scene;
// 提取该角色的对白
const characterLines = scene.beats
.filter(b => b.speaker === testCase.expectedPersona.name && b.line)
.map(b => ({
line: b.line,
delivery: b.lineDelivery
}));
console.log(` ✅ 生成成功,${testCase.expectedPersona.name}${characterLines.length} 句对白`);
if (characterLines.length > 0) {
console.log(` 💬 对白示例:`);
characterLines.slice(0, 3).forEach(l => {
console.log(` "${l.line}"${l.delivery ? ` [${l.delivery}]` : ""}`);
});
} else {
console.log(` ⚠️ 该角色未说话(可能未出场)`);
}
results.push({
testCase: testCase.name,
character: testCase.expectedPersona.name,
linesGenerated: characterLines.length,
lines: characterLines,
passed: characterLines.length > 0
});
} catch (err) {
console.error(` ❌ 请求失败: ${err.message}`);
}
await new Promise(resolve => setTimeout(resolve, 2000));
}
// 输出统计
console.log("\n\n" + "═".repeat(60));
console.log("📊 Task 20 统计结果");
console.log("═".repeat(60));
console.log(`测试用例: ${results.length} / ${testCases.length}`);
console.log(`通过用例: ${results.filter(r => r.passed).length}`);
console.log(`\n💡 需要人工检查对白是否体现 persona 特征`);
const reportPath = join(__dirname, "../.spec-workflow/specs/prompt-architecture-redesign/task20-report.json");
await fs.writeFile(reportPath, JSON.stringify({
timestamp: new Date().toISOString(),
results
}, null, 2));
console.log(`\n📄 详细报告已保存: ${reportPath}`);
}
// ──────────────────────────────────────────────────────────────────────
// Task 21: 世界书触发验证
// ──────────────────────────────────────────────────────────────────────
async function task21_worldBookTrigger() {
console.log("📋 Task 21: 世界书触发验证\n");
const worldBooks = [{
id: "test-wb",
name: "测试世界书",
entries: [
{
id: "const-1",
keys: [],
content: "这所学校位于县城西郊,建校已有50年历史",
position: "constant",
priority: 10
},
{
id: "trig-1",
keys: ["教室", "上课"],
content: "3年2班教室位于教学楼3层,共有42个座位,窗户朝南",
position: "triggered",
priority: 5
},
{
id: "trig-2",
keys: ["食堂", "午饭"],
content: "学校食堂在一楼,有A、B两个窗口,A窗口供应盖饭,B窗口供应面食",
position: "triggered",
priority: 5
}
]
}];
const scenarios = [
{ seed: "主角走进3年2班教室,准备上课", expectedTrigger: ["trig-1"], keywords: ["教室", "上课"] },
{ seed: "放学后,主角去学校食堂吃午饭", expectedTrigger: ["trig-2"], keywords: ["食堂", "午饭"] },
{ seed: "主角在操场上遇到了朋友", expectedTrigger: [], keywords: [] },
{ seed: "主角在图书馆看书", expectedTrigger: [], keywords: [] },
{ seed: "主角在教室里和同学讨论作业", expectedTrigger: ["trig-1"], keywords: ["教室"] }
];
const results = [];
for (let i = 0; i < scenarios.length; i++) {
const scenario = scenarios[i];
console.log(`\n🎬 [${i + 1}/${scenarios.length}] ${scenario.seed}`);
console.log(` 期望触发: ${scenario.expectedTrigger.length > 0 ? scenario.expectedTrigger.join(", ") : "无"}`);
try {
// Step 1: /api/start to get a session (worldBooks injected afterward)
const startRes = await fetch(`${BASE_URL}/api/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
worldSetting: `现代校园。${scenario.seed}`,
styleGuide: "日常写实",
orientation: "landscape"
})
});
if (!startRes.ok) {
console.error(` ❌ Start API 错误: ${startRes.status}`);
continue;
}
const startData = await startRes.json();
// Reconstruct session with worldBooks injected
const session = {
id: startData.sessionId,
createdAt: Date.now(),
worldSetting: `现代校园。${scenario.seed}`,
styleGuide: "日常写实",
history: [{
scene: startData.scene,
visitedBeatIds: [startData.scene.entryBeatId || startData.scene.beats[0].id],
exit: { kind: "choice", label: "继续", nextSceneSeed: scenario.seed }
}],
characters: startData.characters,
storyState: startData.storyState,
orientation: "landscape",
worldBooks
};
// Step 2: /api/scene with worldBooks in session (this is where lore injection happens)
const sceneRes = await fetch(`${BASE_URL}/api/scene`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session })
});
if (!sceneRes.ok) {
console.error(` ❌ Scene API 错误: ${sceneRes.status}`);
continue;
}
const sceneData = await sceneRes.json();
const scene = sceneData.scene;
const texts = scene?.beats?.map(b => [b.narration, b.line].filter(Boolean).join(" ")).join(" ") || "";
// 检查是否引用了世界书内容
const constReferenced = texts.includes("县城西郊") || texts.includes("50年");
const triggeredEntries = [];
for (const expected of scenario.expectedTrigger) {
const entry = worldBooks[0].entries.find(e => e.id === expected);
if (entry) {
const referenced = texts.includes("42个座位") || texts.includes("A、B两个窗口") ||
texts.includes("3层") || texts.includes("窗户朝南") ||
texts.includes("盖饭") || texts.includes("面食");
if (referenced) triggeredEntries.push(expected);
}
}
const passed = (scenario.expectedTrigger.length === 0 && triggeredEntries.length === 0) ||
(scenario.expectedTrigger.length > 0 && triggeredEntries.length > 0);
console.log(` ✅ 生成成功 (${texts.length} 字)`);
console.log(` Constant 条目引用: ${constReferenced ? "是" : "否"}`);
console.log(` Triggered 条目触发: ${triggeredEntries.length > 0 ? triggeredEntries.join(", ") : "无"}`);
console.log(` 验证结果: ${passed ? "✓ 通过" : "✗ 失败"}`);
results.push({
seed: scenario.seed,
expectedTrigger: scenario.expectedTrigger,
actualTrigger: triggeredEntries,
constReferenced,
passed
});
} catch (err) {
console.error(` ❌ 请求失败: ${err.message}`);
}
await new Promise(resolve => setTimeout(resolve, 2000));
}
// 输出统计
console.log("\n\n" + "═".repeat(60));
console.log("📊 Task 21 统计结果");
console.log("═".repeat(60));
console.log(`测试场景: ${results.length} / ${scenarios.length}`);
console.log(`通过场景: ${results.filter(r => r.passed).length}`);
console.log(`触发准确率: ${(results.filter(r => r.passed).length / results.length * 100).toFixed(1)}%`);
console.log(`\n期望目标: 触发准确率 ≥90%`);
const reportPath = join(__dirname, "../.spec-workflow/specs/prompt-architecture-redesign/task21-report.json");
await fs.writeFile(reportPath, JSON.stringify({
timestamp: new Date().toISOString(),
summary: {
total: results.length,
passed: results.filter(r => r.passed).length,
accuracy: results.filter(r => r.passed).length / results.length
},
details: results
}, null, 2));
console.log(`\n📄 详细报告已保存: ${reportPath}`);
}
// ──────────────────────────────────────────────────────────────────────
// 主函数
// ──────────────────────────────────────────────────────────────────────
async function main() {
try {
switch (TASK) {
case "18":
await task18_forbiddenWords();
break;
case "20":
await task20_personaInjection();
break;
case "21":
await task21_worldBookTrigger();
break;
default:
console.error(`❌ 未知任务: ${TASK}`);
console.log(`\n可用任务: 18, 20, 21`);
process.exit(1);
}
} catch (err) {
console.error(`\n💥 执行失败: ${err.message}`);
console.error(err.stack);
process.exit(1);
}
}
main();